Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 70a2246e8b | |||
| b7e3dac661 | |||
| 2e4a1996c8 | |||
| fe4e952853 | |||
| 18654b6806 | |||
| ee7d00c0b1 | |||
| 9cd1a94369 | |||
| 470e85c0ff |
@@ -113,11 +113,6 @@ const allowedUncalledTauriCommands = [
|
|||||||
'chat_with_game_creator_agent',
|
'chat_with_game_creator_agent',
|
||||||
'check_ui_editor_font_glyph_coverage',
|
'check_ui_editor_font_glyph_coverage',
|
||||||
'create_ui_design_resource',
|
'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_launcher_window',
|
||||||
'open_game_creator_workspace_window',
|
'open_game_creator_workspace_window',
|
||||||
'read_direct_project_conversation',
|
'read_direct_project_conversation',
|
||||||
|
|||||||
@@ -9,10 +9,6 @@ import {
|
|||||||
const agcDevHost = '127.0.0.1';
|
const agcDevHost = '127.0.0.1';
|
||||||
const legacyAgcDevPort = 3080;
|
const legacyAgcDevPort = 3080;
|
||||||
const agcVitePortEnvKey = 'GENARRATIVE_AGC_VITE_PORT';
|
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) {
|
function readConfiguredAgcDevPort(env = process.env) {
|
||||||
const rawPort = String(env[agcVitePortEnvKey] ?? '').trim();
|
const rawPort = String(env[agcVitePortEnvKey] ?? '').trim();
|
||||||
@@ -103,100 +99,13 @@ function withAgcDevEndpointEnv(endpoint, env = process.env) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function readConfiguredAgcAdminWebPort(env = process.env) {
|
|
||||||
const rawPort = String(env[agcAdminWebPortEnvKey] ?? '').trim();
|
|
||||||
if (!rawPort) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const port = normalizePort(rawPort, -1);
|
|
||||||
if (port < 1024) {
|
|
||||||
throw new Error(`${agcAdminWebPortEnvKey} 必须是 1024-65535 的有效端口`);
|
|
||||||
}
|
|
||||||
return port;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createAgcAdminWebEndpoint(port, portRange = null) {
|
|
||||||
const origin = `http://${agcAdminWebHost}:${port}`;
|
|
||||||
return {
|
|
||||||
host: agcAdminWebHost,
|
|
||||||
port,
|
|
||||||
origin,
|
|
||||||
basePath: '/admin/',
|
|
||||||
url: `${origin}/admin/`,
|
|
||||||
portRange,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// AGC 开发态的后台 Web 与 `npm run dev` 的后台 Vite 共用同一套优先端口约定:
|
|
||||||
// Linux 取当前用户端口段的 `start + 3` 槽位,非 Linux 保留 `3102` 兼容首选并允许统一漂移。
|
|
||||||
async function resolveAgcAdminWebEndpoint({
|
|
||||||
env = process.env,
|
|
||||||
platform = process.platform,
|
|
||||||
strictConfigured = false,
|
|
||||||
reservedPorts = [],
|
|
||||||
reservePortRange = reserveLinuxDevPortRange,
|
|
||||||
findPort = findAvailablePort,
|
|
||||||
} = {}) {
|
|
||||||
const configuredPort = readConfiguredAgcAdminWebPort(env);
|
|
||||||
let portRange = null;
|
|
||||||
let preferredPort = configuredPort ?? legacyAgcAdminWebPort;
|
|
||||||
|
|
||||||
if (platform === 'linux') {
|
|
||||||
const allocation = await reservePortRange({ env });
|
|
||||||
if (!allocation?.range) {
|
|
||||||
throw new Error('无法取得当前 Linux 用户的 dev 端口段');
|
|
||||||
}
|
|
||||||
portRange = allocation.range;
|
|
||||||
const mappedAdminWebPort = mapDevPortsToPortRange(portRange)?.adminWebPort;
|
|
||||||
if (!Number.isInteger(mappedAdminWebPort)) {
|
|
||||||
throw new Error(
|
|
||||||
`当前 Linux dev 端口段 ${portRange.label} 缺少后台 Web 槽位;请先迁移为至少 6 个端口且不与其它用户重叠的端口段`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
preferredPort = configuredPort ?? mappedAdminWebPort;
|
|
||||||
}
|
|
||||||
|
|
||||||
const reservedPortSet = new Set(
|
|
||||||
reservedPorts.filter((value) => Number.isInteger(value) && value > 0),
|
|
||||||
);
|
|
||||||
const port = await findPort({
|
|
||||||
host: agcAdminWebHost,
|
|
||||||
preferredPort,
|
|
||||||
portRange,
|
|
||||||
reservedPorts: reservedPortSet,
|
|
||||||
strict: strictConfigured && configuredPort != null,
|
|
||||||
});
|
|
||||||
console.log(
|
|
||||||
formatPortDecision({
|
|
||||||
name: 'ai-game-creator-shell-admin-web',
|
|
||||||
host: agcAdminWebHost,
|
|
||||||
preferredPort,
|
|
||||||
resolvedPort: port,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
if (portRange) {
|
|
||||||
console.log(
|
|
||||||
`[ai-game-creator-shell] admin-web port-range: ${portRange.label}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return createAgcAdminWebEndpoint(port, portRange);
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
export {
|
||||||
agcAdminWebHost,
|
|
||||||
agcAdminWebPortEnvKey,
|
|
||||||
agcDevHost,
|
agcDevHost,
|
||||||
agcVitePortEnvKey,
|
agcVitePortEnvKey,
|
||||||
createAgcAdminWebEndpoint,
|
|
||||||
createAgcDevEndpoint,
|
createAgcDevEndpoint,
|
||||||
legacyAgcAdminWebPort,
|
|
||||||
legacyAgcDevPort,
|
legacyAgcDevPort,
|
||||||
readAgcDevEndpoint,
|
readAgcDevEndpoint,
|
||||||
readConfiguredAgcAdminWebPort,
|
|
||||||
readConfiguredAgcDevPort,
|
readConfiguredAgcDevPort,
|
||||||
resolveAgcAdminWebEndpoint,
|
|
||||||
resolveAgcDevEndpoint,
|
resolveAgcDevEndpoint,
|
||||||
withAgcDevEndpointEnv,
|
withAgcDevEndpointEnv,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,14 +14,12 @@ import {
|
|||||||
import {
|
import {
|
||||||
agcVitePortEnvKey,
|
agcVitePortEnvKey,
|
||||||
readAgcDevEndpoint,
|
readAgcDevEndpoint,
|
||||||
resolveAgcAdminWebEndpoint,
|
|
||||||
resolveAgcDevEndpoint,
|
resolveAgcDevEndpoint,
|
||||||
withAgcDevEndpointEnv,
|
withAgcDevEndpointEnv,
|
||||||
} from './dev-port.mjs';
|
} from './dev-port.mjs';
|
||||||
|
|
||||||
const appRoot = fileURLToPath(new URL('..', import.meta.url));
|
const appRoot = fileURLToPath(new URL('..', import.meta.url));
|
||||||
const repoRoot = resolve(appRoot, '../..');
|
const repoRoot = resolve(appRoot, '../..');
|
||||||
const adminWebDir = resolve(repoRoot, 'apps/admin-web');
|
|
||||||
const devStackStatePath = resolve(repoRoot, '.app/dev-stack.json');
|
const devStackStatePath = resolve(repoRoot, '.app/dev-stack.json');
|
||||||
const apiServerExePath = resolve(
|
const apiServerExePath = resolve(
|
||||||
repoRoot,
|
repoRoot,
|
||||||
@@ -34,8 +32,6 @@ const backendSpacetimeDataDir = resolve(
|
|||||||
repoRoot,
|
repoRoot,
|
||||||
'server-rs/.spacetimedb/ai-game-creator/data',
|
'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 npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
||||||
const childLifecycles = new WeakMap();
|
const childLifecycles = new WeakMap();
|
||||||
|
|
||||||
@@ -204,122 +200,36 @@ function urlPort(url) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 端口归属探测脚本。历史实现用 `Get-NetTCPConnection` 取监听进程,而它底层走
|
// 读取端口当前真正的监听进程身份。返回 null 表示探测本身不可用(例如缺少
|
||||||
// WMI:实测单端口单次 11.2 秒、再叠加每个 PID 的 `Get-CimInstance` 3.3 秒,
|
// Get-NetTCPConnection),此时调用方必须退化为旧行为,不能让本地启动直接失败。
|
||||||
// 一轮探测约 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(
|
function readWindowsPortOwnerIdentities(
|
||||||
ports,
|
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))];
|
const uniquePorts = [...new Set(ports.filter((port) => port > 0))];
|
||||||
if (uniquePorts.length === 0) {
|
if (uniquePorts.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const knownCommandLines = {};
|
const command = [
|
||||||
for (const [processId, record] of [...commandLineCache]) {
|
'$ErrorActionPreference = "SilentlyContinue"',
|
||||||
if (record && now() - record.at < commandLineTtlMs) {
|
'$ports = ($env:GENARRATIVE_QUERY_PORTS -split ",") | Where-Object { $_ }',
|
||||||
knownCommandLines[processId] = record.commandLine;
|
'$result = @()',
|
||||||
} else {
|
'foreach ($port in $ports) {',
|
||||||
commandLineCache.delete(processId);
|
' $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 }',
|
||||||
const childEnv = {
|
'}',
|
||||||
...env,
|
'ConvertTo-Json -InputObject @($result) -Compress',
|
||||||
GENARRATIVE_QUERY_PORTS: uniquePorts.join(','),
|
].join('\n');
|
||||||
};
|
|
||||||
if (Object.keys(knownCommandLines).length > 0) {
|
|
||||||
childEnv.GENARRATIVE_KNOWN_COMMAND_LINES =
|
|
||||||
JSON.stringify(knownCommandLines);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = spawnImpl(
|
const result = spawnImpl(
|
||||||
'powershell.exe',
|
'powershell.exe',
|
||||||
[
|
['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', command],
|
||||||
'-NoProfile',
|
|
||||||
'-ExecutionPolicy',
|
|
||||||
'Bypass',
|
|
||||||
'-Command',
|
|
||||||
windowsPortOwnerProbeCommand,
|
|
||||||
],
|
|
||||||
{
|
{
|
||||||
encoding: 'utf8',
|
encoding: 'utf8',
|
||||||
env: childEnv,
|
env: { ...env, GENARRATIVE_QUERY_PORTS: uniquePorts.join(',') },
|
||||||
maxBuffer: 8 * 1024 * 1024,
|
maxBuffer: 8 * 1024 * 1024,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -330,22 +240,9 @@ function readWindowsPortOwnerIdentities(
|
|||||||
const owners = new Map();
|
const owners = new Map();
|
||||||
for (const entry of parseWindowsProcessSnapshot(result.stdout)) {
|
for (const entry of parseWindowsProcessSnapshot(result.stdout)) {
|
||||||
const port = Number(entry?.port);
|
const port = Number(entry?.port);
|
||||||
if (!Number.isInteger(port) || port <= 0) {
|
if (Number.isInteger(port) && port > 0) {
|
||||||
continue;
|
owners.set(port, entry);
|
||||||
}
|
}
|
||||||
const processId = Number(entry?.processId);
|
|
||||||
if (
|
|
||||||
Number.isInteger(processId) &&
|
|
||||||
processId > 0 &&
|
|
||||||
typeof entry?.commandLine === 'string' &&
|
|
||||||
entry.commandLine
|
|
||||||
) {
|
|
||||||
commandLineCache.set(processId, {
|
|
||||||
commandLine: entry.commandLine,
|
|
||||||
at: now(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
owners.set(port, entry);
|
|
||||||
}
|
}
|
||||||
return owners;
|
return owners;
|
||||||
}
|
}
|
||||||
@@ -982,102 +879,10 @@ async function startVite(apiTarget, endpoint = readAgcDevEndpoint()) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function readAdminWebEnabled(env = process.env) {
|
|
||||||
return String(env[agcDevAdminWebEnvKey] ?? '').trim() !== '0';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 后台 Web 与 AGC Vite 一样直接由本启动器持有,不经过 `dev.mjs admin-web`:
|
|
||||||
// 后者会整体重写 `.app/dev-stack.json`,把本次配套后端的状态覆盖掉。
|
|
||||||
function startAdminWeb(
|
|
||||||
apiUrl,
|
|
||||||
endpoint,
|
|
||||||
{ env = process.env, spawnImpl = spawnChild } = {},
|
|
||||||
) {
|
|
||||||
return spawnImpl(
|
|
||||||
npm,
|
|
||||||
[
|
|
||||||
'--prefix',
|
|
||||||
'../..',
|
|
||||||
'exec',
|
|
||||||
'vite',
|
|
||||||
'--',
|
|
||||||
'--host',
|
|
||||||
endpoint.host,
|
|
||||||
'--port',
|
|
||||||
String(endpoint.port),
|
|
||||||
'--strictPort',
|
|
||||||
],
|
|
||||||
{
|
|
||||||
cwd: adminWebDir,
|
|
||||||
env: {
|
|
||||||
...env,
|
|
||||||
ADMIN_API_TARGET: apiUrl,
|
|
||||||
GENARRATIVE_API_TARGET: apiUrl,
|
|
||||||
GENARRATIVE_API_PORT: String(urlPort(apiUrl) || 8082),
|
|
||||||
ADMIN_WEB_BASE: endpoint.basePath,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatStartupSummary({
|
|
||||||
frontendUrl = '',
|
|
||||||
apiUrl = '',
|
|
||||||
adminWebUrl = '',
|
|
||||||
spacetimeUrl = '',
|
|
||||||
bgfilterWorkerUrl = '',
|
|
||||||
} = {}) {
|
|
||||||
const segments = [
|
|
||||||
['前端', frontendUrl],
|
|
||||||
['后端', apiUrl],
|
|
||||||
['后台', adminWebUrl],
|
|
||||||
['数据库', spacetimeUrl],
|
|
||||||
['bgfilter-worker', bgfilterWorkerUrl],
|
|
||||||
]
|
|
||||||
.filter(([, value]) => Boolean(value))
|
|
||||||
.map(([label, value]) => `${label} ${value}`);
|
|
||||||
return `[ai-game-creator-shell] 启动汇总: ${segments.join(' | ')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 后台 Web 是可选联调服务:端口解析或启动失败只告警,不能阻断 AGC 客户端与配套后端。
|
|
||||||
async function ensureAdminWeb({
|
|
||||||
apiUrl,
|
|
||||||
reservedPorts = [],
|
|
||||||
env = process.env,
|
|
||||||
enabled = readAdminWebEnabled(env),
|
|
||||||
resolveEndpoint = resolveAgcAdminWebEndpoint,
|
|
||||||
spawnAdminWeb = startAdminWeb,
|
|
||||||
waitForExit = waitForChildTermination,
|
|
||||||
warn = (message) => console.warn(message),
|
|
||||||
} = {}) {
|
|
||||||
if (!enabled) {
|
|
||||||
return { endpoint: null, child: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const endpoint = await resolveEndpoint({ env, reservedPorts });
|
|
||||||
const child = spawnAdminWeb(apiUrl, endpoint, { env });
|
|
||||||
waitForExit(child).then((failure) => {
|
|
||||||
warn(
|
|
||||||
`[ai-game-creator-shell] 后台 Web 已退出(${formatChildFailure(failure)}),AGC 继续运行。`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
return { endpoint, child };
|
|
||||||
} catch (error) {
|
|
||||||
warn(
|
|
||||||
`[ai-game-creator-shell] 后台 Web 未能启动(${
|
|
||||||
error instanceof Error ? error.message : String(error)
|
|
||||||
}),AGC 继续运行。`,
|
|
||||||
);
|
|
||||||
return { endpoint: null, child: null };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
let backendChild = null;
|
let backendChild = null;
|
||||||
let startedBackend = false;
|
let startedBackend = false;
|
||||||
let viteChild = null;
|
let viteChild = null;
|
||||||
let adminWebChild = null;
|
|
||||||
let shutdownSignal = '';
|
let shutdownSignal = '';
|
||||||
const signalHandlers = new Map();
|
const signalHandlers = new Map();
|
||||||
|
|
||||||
@@ -1100,7 +905,6 @@ async function main() {
|
|||||||
const handler = () => {
|
const handler = () => {
|
||||||
shutdownSignal = signal;
|
shutdownSignal = signal;
|
||||||
stopChild(viteChild, signal);
|
stopChild(viteChild, signal);
|
||||||
stopChild(adminWebChild, signal);
|
|
||||||
stopChild(backendChild, signal);
|
stopChild(backendChild, signal);
|
||||||
// 立刻清扫,避免外层 taskkill /F 抢在 finally 之前把本进程杀掉。
|
// 立刻清扫,避免外层 taskkill /F 抢在 finally 之前把本进程杀掉。
|
||||||
sweepStartedBackend();
|
sweepStartedBackend();
|
||||||
@@ -1133,25 +937,6 @@ async function main() {
|
|||||||
throw new Error(`启动期收到 ${shutdownSignal},已停止前端服务`);
|
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);
|
const children = [backendChild, viteChild].filter(Boolean);
|
||||||
if (children.length === 0) {
|
if (children.length === 0) {
|
||||||
return 0;
|
return 0;
|
||||||
@@ -1161,12 +946,10 @@ async function main() {
|
|||||||
children.map((child) => waitForChildTermination(child)),
|
children.map((child) => waitForChildTermination(child)),
|
||||||
);
|
);
|
||||||
stopChild(viteChild);
|
stopChild(viteChild);
|
||||||
stopChild(adminWebChild);
|
|
||||||
stopChild(backendChild);
|
stopChild(backendChild);
|
||||||
return failure.type === 'error' || failure.signal ? 1 : (failure.code ?? 0);
|
return failure.type === 'error' || failure.signal ? 1 : (failure.code ?? 0);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
stopChild(viteChild);
|
stopChild(viteChild);
|
||||||
stopChild(adminWebChild);
|
|
||||||
stopChild(backendChild);
|
stopChild(backendChild);
|
||||||
console.error(
|
console.error(
|
||||||
`[ai-game-creator-shell] ${error instanceof Error ? error.message : String(error)}`,
|
`[ai-game-creator-shell] ${error instanceof Error ? error.message : String(error)}`,
|
||||||
@@ -1175,7 +958,6 @@ async function main() {
|
|||||||
} finally {
|
} finally {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
terminateChildTree(viteChild),
|
terminateChildTree(viteChild),
|
||||||
terminateChildTree(adminWebChild),
|
|
||||||
terminateChildTree(backendChild),
|
terminateChildTree(backendChild),
|
||||||
]);
|
]);
|
||||||
sweepStartedBackend();
|
sweepStartedBackend();
|
||||||
@@ -1193,12 +975,9 @@ function isDirectModuleExecution() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
agcDevAdminWebEnvKey,
|
|
||||||
ensureAdminWeb,
|
|
||||||
ensureBackend,
|
ensureBackend,
|
||||||
formatChildFailure,
|
formatChildFailure,
|
||||||
formatOwnerLabel,
|
formatOwnerLabel,
|
||||||
formatStartupSummary,
|
|
||||||
isAiGameCreatorServer,
|
isAiGameCreatorServer,
|
||||||
isBackendReady,
|
isBackendReady,
|
||||||
isDirectModuleExecution,
|
isDirectModuleExecution,
|
||||||
@@ -1206,7 +985,6 @@ export {
|
|||||||
isWorktreeApiServerOwner,
|
isWorktreeApiServerOwner,
|
||||||
isWorktreeSpacetimeOwner,
|
isWorktreeSpacetimeOwner,
|
||||||
preflightExistingVite,
|
preflightExistingVite,
|
||||||
readAdminWebEnabled,
|
|
||||||
readBackendServiceFailure,
|
readBackendServiceFailure,
|
||||||
readChildFailure,
|
readChildFailure,
|
||||||
readExistingViteServer,
|
readExistingViteServer,
|
||||||
@@ -1215,7 +993,6 @@ export {
|
|||||||
resolveBackendTargetsFromState,
|
resolveBackendTargetsFromState,
|
||||||
runWindowsTaskkill,
|
runWindowsTaskkill,
|
||||||
spawnChild,
|
spawnChild,
|
||||||
startAdminWeb,
|
|
||||||
stopChild,
|
stopChild,
|
||||||
terminateChildTree,
|
terminateChildTree,
|
||||||
verifyAgcBackendOwnership,
|
verifyAgcBackendOwnership,
|
||||||
|
|||||||
@@ -2829,8 +2829,6 @@ impl CodexAppServerConnection {
|
|||||||
callback(&platform_llm::LlmStreamDelta {
|
callback(&platform_llm::LlmStreamDelta {
|
||||||
accumulated_text: streamed_text.clone(),
|
accumulated_text: streamed_text.clone(),
|
||||||
delta_text: delta,
|
delta_text: delta,
|
||||||
accumulated_reasoning: String::new(),
|
|
||||||
reasoning_delta: String::new(),
|
|
||||||
finish_reason: None,
|
finish_reason: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -3135,7 +3133,6 @@ fn parse_game_creator_codex_app_server_text(
|
|||||||
} else {
|
} else {
|
||||||
String::new()
|
String::new()
|
||||||
},
|
},
|
||||||
reasoning: String::new(),
|
|
||||||
finish_reason: Some("stop".to_string()),
|
finish_reason: Some("stop".to_string()),
|
||||||
response_id: Some(thread_id.to_string()),
|
response_id: Some(thread_id.to_string()),
|
||||||
usage: None,
|
usage: None,
|
||||||
|
|||||||
@@ -589,7 +589,6 @@ fn parse_game_creator_codex_cli_response(
|
|||||||
} else {
|
} else {
|
||||||
String::new()
|
String::new()
|
||||||
},
|
},
|
||||||
reasoning: String::new(),
|
|
||||||
finish_reason: Some("stop".to_string()),
|
finish_reason: Some("stop".to_string()),
|
||||||
response_id,
|
response_id,
|
||||||
usage,
|
usage,
|
||||||
|
|||||||
@@ -49,18 +49,6 @@ pub(crate) struct DesignView {
|
|||||||
messages: Vec<DesignMessage>,
|
messages: Vec<DesignMessage>,
|
||||||
running: bool,
|
running: bool,
|
||||||
can_retry: bool,
|
can_retry: bool,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
reasoning_text: Option<String>,
|
|
||||||
reasoning_entries: Vec<DesignReasoningEntry>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub(crate) struct DesignReasoningEntry {
|
|
||||||
id: String,
|
|
||||||
text: String,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
message_id: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
#[derive(Clone, Debug, Serialize)]
|
||||||
@@ -77,7 +65,6 @@ pub(crate) struct DesignEvent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn design_view(session: &DesignSession, running: bool) -> DesignView {
|
fn design_view(session: &DesignSession, running: bool) -> DesignView {
|
||||||
let reasoning_entries = persisted_design_reasoning_entries(session);
|
|
||||||
DesignView {
|
DesignView {
|
||||||
session: DesignSessionSummary {
|
session: DesignSessionSummary {
|
||||||
session_id: session.session_id.clone(),
|
session_id: session.session_id.clone(),
|
||||||
@@ -95,124 +82,9 @@ fn design_view(session: &DesignSession, running: bool) -> DesignView {
|
|||||||
&& session.turn.as_ref().is_some_and(|turn| turn.pending)
|
&& session.turn.as_ref().is_some_and(|turn| turn.pending)
|
||||||
&& session.pending_approval.is_none()
|
&& session.pending_approval.is_none()
|
||||||
&& session.pending_clarification.is_none(),
|
&& session.pending_clarification.is_none(),
|
||||||
reasoning_text: reasoning_entries.last().map(|entry| entry.text.clone()),
|
|
||||||
reasoning_entries,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn reasoning_text_from_history_item(item: &Value) -> Option<String> {
|
|
||||||
if item.get("type").and_then(Value::as_str) != Some("reasoning") {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let mut text = String::new();
|
|
||||||
if let Some(summary) = item.get("summary").and_then(Value::as_array) {
|
|
||||||
for part in summary {
|
|
||||||
if let Some(value) = part.get("text").and_then(Value::as_str) {
|
|
||||||
text.push_str(value.trim());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(content) = item.get("content").and_then(Value::as_array) {
|
|
||||||
for part in content {
|
|
||||||
let part_type = part.get("type").and_then(Value::as_str).unwrap_or_default();
|
|
||||||
if matches!(
|
|
||||||
part_type,
|
|
||||||
"reasoning" | "reasoning_content" | "reasoning_text" | "analysis" | "thinking"
|
|
||||||
) {
|
|
||||||
if let Some(value) = part.get("text").and_then(Value::as_str) {
|
|
||||||
text.push_str(value.trim());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(!text.trim().is_empty()).then_some(text)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn persisted_design_reasoning_entries(session: &DesignSession) -> Vec<DesignReasoningEntry> {
|
|
||||||
// Responses history contains tool-only provider responses. Their reasoning is
|
|
||||||
// followed by function calls and only the next provider response may contain
|
|
||||||
// visible assistant text, so pairing on the next `message` item makes the
|
|
||||||
// earlier reasoning look like an orphan and moves it to the bottom of the UI.
|
|
||||||
// Both persisted streams retain user-turn boundaries; pair reasoning and
|
|
||||||
// visible assistant messages by their response order within each turn.
|
|
||||||
let mut assistant_groups: Vec<Vec<String>> = vec![Vec::new()];
|
|
||||||
for message in &session.messages {
|
|
||||||
if message.role == "user" {
|
|
||||||
assistant_groups.push(Vec::new());
|
|
||||||
} else if message.role == "assistant" {
|
|
||||||
assistant_groups
|
|
||||||
.last_mut()
|
|
||||||
.expect("assistant group always exists")
|
|
||||||
.push(message.id.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let mut entries = Vec::new();
|
|
||||||
let mut group_index = 0;
|
|
||||||
let mut assistant_index = 0;
|
|
||||||
let mut sequence = 0_u64;
|
|
||||||
let mut current_reasoning = Vec::new();
|
|
||||||
let mut pending_reasoning = Vec::new();
|
|
||||||
let mut saw_response_output = false;
|
|
||||||
|
|
||||||
for item in &session.history {
|
|
||||||
if item.get("role").and_then(Value::as_str) == Some("user") {
|
|
||||||
if !pending_reasoning.is_empty() || !current_reasoning.is_empty() {
|
|
||||||
pending_reasoning.append(&mut current_reasoning);
|
|
||||||
}
|
|
||||||
group_index += 1;
|
|
||||||
assistant_index = 0;
|
|
||||||
saw_response_output = false;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if item.get("type").and_then(Value::as_str) == Some("reasoning") {
|
|
||||||
if saw_response_output {
|
|
||||||
pending_reasoning.append(&mut current_reasoning);
|
|
||||||
saw_response_output = false;
|
|
||||||
}
|
|
||||||
if let Some(text) = reasoning_text_from_history_item(item) {
|
|
||||||
sequence += 1;
|
|
||||||
current_reasoning.push(DesignReasoningEntry {
|
|
||||||
id: item
|
|
||||||
.get("id")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::to_string)
|
|
||||||
.unwrap_or_else(|| format!("reasoning-{sequence}")),
|
|
||||||
text,
|
|
||||||
message_id: None,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if item.get("role").and_then(Value::as_str) == Some("assistant")
|
|
||||||
|| item.get("type").and_then(Value::as_str) == Some("message")
|
|
||||||
{
|
|
||||||
pending_reasoning.extend(current_reasoning.drain(..));
|
|
||||||
let assistant_id = assistant_groups
|
|
||||||
.get(group_index)
|
|
||||||
.and_then(|ids| ids.get(assistant_index))
|
|
||||||
.cloned();
|
|
||||||
assistant_index += 1;
|
|
||||||
for mut entry in pending_reasoning.drain(..) {
|
|
||||||
entry.message_id = assistant_id.clone();
|
|
||||||
entries.push(entry);
|
|
||||||
}
|
|
||||||
saw_response_output = false;
|
|
||||||
} else if item.get("type").is_some() {
|
|
||||||
saw_response_output = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pending_reasoning.append(&mut current_reasoning);
|
|
||||||
let fallback_id = assistant_groups
|
|
||||||
.get(group_index)
|
|
||||||
.and_then(|ids| ids.last())
|
|
||||||
.cloned();
|
|
||||||
for mut entry in pending_reasoning {
|
|
||||||
entry.message_id = fallback_id.clone();
|
|
||||||
entries.push(entry);
|
|
||||||
}
|
|
||||||
entries
|
|
||||||
}
|
|
||||||
|
|
||||||
fn design_event(
|
fn design_event(
|
||||||
root: &Path,
|
root: &Path,
|
||||||
turn_id: &str,
|
turn_id: &str,
|
||||||
@@ -232,17 +104,6 @@ fn design_event(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn design_reasoning_event(
|
|
||||||
root: &Path,
|
|
||||||
turn_id: &str,
|
|
||||||
id: Option<&str>,
|
|
||||||
reasoning: String,
|
|
||||||
) -> DesignEvent {
|
|
||||||
let mut event = design_event(root, turn_id, "reasoning", id, None, None);
|
|
||||||
event.reasoning_text = Some(reasoning);
|
|
||||||
event
|
|
||||||
}
|
|
||||||
|
|
||||||
fn design_project_id(root: &Path) -> Result<String, String> {
|
fn design_project_id(root: &Path) -> Result<String, String> {
|
||||||
validate_project_root(root)?;
|
validate_project_root(root)?;
|
||||||
Ok(read_existing_manifest_for_project(root)?.project_id)
|
Ok(read_existing_manifest_for_project(root)?.project_id)
|
||||||
@@ -601,7 +462,6 @@ fn build_design_request(
|
|||||||
.with_tool_choice(platform_llm::LlmToolChoice::Auto)
|
.with_tool_choice(platform_llm::LlmToolChoice::Auto)
|
||||||
.with_web_search(false);
|
.with_web_search(false);
|
||||||
apply_game_creator_llm_reasoning_effort(request, llm)
|
apply_game_creator_llm_reasoning_effort(request, llm)
|
||||||
.map(|request| request.with_reasoning_capture(true))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 调试队列只接收副本,写盘慢或失败时丢弃,不参与会话恢复。
|
// 调试队列只接收副本,写盘慢或失败时丢弃,不参与会话恢复。
|
||||||
@@ -688,15 +548,8 @@ async fn request_design_provider(
|
|||||||
Some(String::new()),
|
Some(String::new()),
|
||||||
None,
|
None,
|
||||||
));
|
));
|
||||||
emit(design_reasoning_event(
|
|
||||||
root,
|
|
||||||
&turn_id,
|
|
||||||
Some(&message_id),
|
|
||||||
String::new(),
|
|
||||||
));
|
|
||||||
let result = if llm.stream {
|
let result = if llm.stream {
|
||||||
let mut stream_sequence = 0_u64;
|
let mut stream_sequence = 0_u64;
|
||||||
let mut emitted_reasoning = String::new();
|
|
||||||
client
|
client
|
||||||
.stream_run(request.clone(), |delta| {
|
.stream_run(request.clone(), |delta| {
|
||||||
stream_sequence = stream_sequence.saturating_add(1);
|
stream_sequence = stream_sequence.saturating_add(1);
|
||||||
@@ -712,33 +565,18 @@ async fn request_design_provider(
|
|||||||
"model": llm.model,
|
"model": llm.model,
|
||||||
"deltaChars": delta.delta_text.chars().count(),
|
"deltaChars": delta.delta_text.chars().count(),
|
||||||
"accumulatedChars": delta.accumulated_text.chars().count(),
|
"accumulatedChars": delta.accumulated_text.chars().count(),
|
||||||
"reasoningDeltaChars": delta.reasoning_delta.chars().count(),
|
|
||||||
"reasoningAccumulatedChars": delta.accumulated_reasoning.chars().count(),
|
|
||||||
"deltaText": delta.delta_text,
|
"deltaText": delta.delta_text,
|
||||||
"finishReason": delta.finish_reason,
|
"finishReason": delta.finish_reason,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
if !delta.delta_text.is_empty() || delta.finish_reason.is_some() {
|
emit(design_event(
|
||||||
emit(design_event(
|
root,
|
||||||
root,
|
&turn_id,
|
||||||
&turn_id,
|
"text",
|
||||||
"text",
|
Some(&message_id),
|
||||||
Some(&message_id),
|
Some(delta.accumulated_text.clone()),
|
||||||
Some(delta.accumulated_text.clone()),
|
None,
|
||||||
None,
|
));
|
||||||
));
|
|
||||||
}
|
|
||||||
if !delta.reasoning_delta.is_empty()
|
|
||||||
|| delta.accumulated_reasoning != emitted_reasoning
|
|
||||||
{
|
|
||||||
emit(design_reasoning_event(
|
|
||||||
root,
|
|
||||||
&turn_id,
|
|
||||||
Some(&message_id),
|
|
||||||
delta.accumulated_reasoning.clone(),
|
|
||||||
));
|
|
||||||
emitted_reasoning = delta.accumulated_reasoning.clone();
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
} else {
|
} else {
|
||||||
@@ -746,14 +584,6 @@ async fn request_design_provider(
|
|||||||
};
|
};
|
||||||
match result {
|
match result {
|
||||||
Ok(response) => {
|
Ok(response) => {
|
||||||
if !response.reasoning.is_empty() {
|
|
||||||
emit(design_reasoning_event(
|
|
||||||
root,
|
|
||||||
&turn_id,
|
|
||||||
Some(&message_id),
|
|
||||||
response.reasoning.clone(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
design_debug(
|
design_debug(
|
||||||
root,
|
root,
|
||||||
"response",
|
"response",
|
||||||
@@ -776,12 +606,6 @@ async fn request_design_provider(
|
|||||||
|| game_creator_agent_runtime_transient_provider_error_kind(&error, false)
|
|| game_creator_agent_runtime_transient_provider_error_kind(&error, false)
|
||||||
.is_none()
|
.is_none()
|
||||||
{
|
{
|
||||||
emit(design_reasoning_event(
|
|
||||||
root,
|
|
||||||
&turn_id,
|
|
||||||
Some(&message_id),
|
|
||||||
String::new(),
|
|
||||||
));
|
|
||||||
return Err(detail);
|
return Err(detail);
|
||||||
}
|
}
|
||||||
tokio::time::sleep(Duration::from_millis(
|
tokio::time::sleep(Duration::from_millis(
|
||||||
@@ -830,24 +654,8 @@ async fn request_scripted_design_provider(
|
|||||||
Some(String::new()),
|
Some(String::new()),
|
||||||
None,
|
None,
|
||||||
));
|
));
|
||||||
emit(design_reasoning_event(
|
|
||||||
root,
|
|
||||||
&turn_id,
|
|
||||||
Some(&message_id),
|
|
||||||
String::new(),
|
|
||||||
));
|
|
||||||
match fake_provider::take() {
|
match fake_provider::take() {
|
||||||
Some(Ok(response)) => {
|
Some(Ok(response)) => return Ok(response),
|
||||||
if !response.reasoning.is_empty() {
|
|
||||||
emit(design_reasoning_event(
|
|
||||||
root,
|
|
||||||
&turn_id,
|
|
||||||
Some(&message_id),
|
|
||||||
response.reasoning.clone(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
return Ok(response);
|
|
||||||
}
|
|
||||||
Some(Err(error)) => {
|
Some(Err(error)) => {
|
||||||
let detail = redact_agent_runtime_error(
|
let detail = redact_agent_runtime_error(
|
||||||
root,
|
root,
|
||||||
@@ -858,24 +666,10 @@ async fn request_scripted_design_provider(
|
|||||||
|| game_creator_agent_runtime_transient_provider_error_kind(&error, false)
|
|| game_creator_agent_runtime_transient_provider_error_kind(&error, false)
|
||||||
.is_none()
|
.is_none()
|
||||||
{
|
{
|
||||||
emit(design_reasoning_event(
|
|
||||||
root,
|
|
||||||
&turn_id,
|
|
||||||
Some(&message_id),
|
|
||||||
String::new(),
|
|
||||||
));
|
|
||||||
return Err(detail);
|
return Err(detail);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None => {
|
None => return Err("假 Provider 脚本耗尽".into()),
|
||||||
emit(design_reasoning_event(
|
|
||||||
root,
|
|
||||||
&turn_id,
|
|
||||||
Some(&message_id),
|
|
||||||
String::new(),
|
|
||||||
));
|
|
||||||
return Err("假 Provider 脚本耗尽".into());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
unreachable!()
|
unreachable!()
|
||||||
@@ -1146,9 +940,7 @@ pub(crate) fn set_design_agent_runtime_mode(
|
|||||||
"design.runtime-mode",
|
"design.runtime-mode",
|
||||||
)?;
|
)?;
|
||||||
if active_runtime.trim() == "game" {
|
if active_runtime.trim() == "game" {
|
||||||
if crate::assets::register_design_artifacts_at(root)? {
|
crate::assets::register_design_artifacts_at(root)?;
|
||||||
advance_agent_runtime_project_revision_locked(root)?;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
write_design_runtime_mode(root, active_runtime.trim())
|
write_design_runtime_mode(root, active_runtime.trim())
|
||||||
}
|
}
|
||||||
@@ -1384,38 +1176,6 @@ mod tests {
|
|||||||
let error = ensure_design_runtime_active(root).expect_err("game mode must reject design");
|
let error = ensure_design_runtime_active(root).expect_err("game mode must reject design");
|
||||||
assert!(error.contains("游戏运行态"));
|
assert!(error.contains("游戏运行态"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn switching_to_game_pairs_design_artifact_registration_with_revision() {
|
|
||||||
let temporary = tempfile::tempdir().expect("create runtime mode root");
|
|
||||||
let root = temporary.path();
|
|
||||||
crate::project::init_local_game_project_at(root, "design-switch-test", "策划切换")
|
|
||||||
.expect("init project");
|
|
||||||
fs::create_dir_all(root.join("design_artifacts/project")).expect("create artifacts");
|
|
||||||
fs::write(root.join("design_artifacts/project/design.md"), "设计内容")
|
|
||||||
.expect("write artifact");
|
|
||||||
|
|
||||||
let before = read_game_creator_agent_runtime_project_revision(root)
|
|
||||||
.expect("read initial revision")
|
|
||||||
.revision;
|
|
||||||
assert_eq!(
|
|
||||||
set_design_agent_runtime_mode(root.to_string_lossy().into_owned(), "game".to_string(),)
|
|
||||||
.expect("switch to game")
|
|
||||||
.active_runtime,
|
|
||||||
"game"
|
|
||||||
);
|
|
||||||
let after = read_game_creator_agent_runtime_project_revision(root)
|
|
||||||
.expect("read committed revision")
|
|
||||||
.revision;
|
|
||||||
assert_eq!(after, before + 1);
|
|
||||||
|
|
||||||
set_design_agent_runtime_mode(root.to_string_lossy().into_owned(), "game".to_string())
|
|
||||||
.expect("repeat switch to game");
|
|
||||||
let repeated = read_game_creator_agent_runtime_project_revision(root)
|
|
||||||
.expect("read repeated revision")
|
|
||||||
.revision;
|
|
||||||
assert_eq!(repeated, after);
|
|
||||||
}
|
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
|
||||||
@@ -1525,7 +1285,6 @@ mod tests {
|
|||||||
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
||||||
model: "fake-design".into(),
|
model: "fake-design".into(),
|
||||||
text: text.into(),
|
text: text.into(),
|
||||||
reasoning: String::new(),
|
|
||||||
finish_reason: Some(if calls.is_empty() {
|
finish_reason: Some(if calls.is_empty() {
|
||||||
"stop".into()
|
"stop".into()
|
||||||
} else {
|
} else {
|
||||||
@@ -1586,110 +1345,6 @@ mod tests {
|
|||||||
.clone()
|
.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn design_request_enables_reasoning_capture_only_for_design_runtime() {
|
|
||||||
let session = new_design_session("project", "quality");
|
|
||||||
let request = build_design_request(&session, &pack(), &GameCreatorLlmConfig::default())
|
|
||||||
.expect("design request");
|
|
||||||
assert!(request.capture_reasoning);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn persisted_reasoning_follows_response_order_across_tool_only_responses() {
|
|
||||||
let mut session = new_design_session("project", "quality");
|
|
||||||
session.messages = vec![
|
|
||||||
DesignMessage {
|
|
||||||
id: "turn:user".into(),
|
|
||||||
role: "user".into(),
|
|
||||||
text: "需求".into(),
|
|
||||||
},
|
|
||||||
DesignMessage {
|
|
||||||
id: "call-1:tool".into(),
|
|
||||||
role: "tool".into(),
|
|
||||||
text: "读取资源".into(),
|
|
||||||
},
|
|
||||||
DesignMessage {
|
|
||||||
id: "turn:response:0".into(),
|
|
||||||
role: "assistant".into(),
|
|
||||||
text: "给出方案".into(),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
session.history = vec![
|
|
||||||
json!({"role":"user", "content":"需求"}),
|
|
||||||
json!({"type":"reasoning", "id":"r1", "content":[{"type":"reasoning_text", "text":"第一段思考"}]}),
|
|
||||||
json!({"type":"function_call", "call_id":"call-1", "name":"read_resource", "arguments":"{}"}),
|
|
||||||
json!({"type":"reasoning", "id":"r2", "content":[{"type":"reasoning_text", "text":"第二段思考"}]}),
|
|
||||||
json!({"type":"message", "role":"assistant", "content":[{"type":"output_text", "text":"给出方案"}]}),
|
|
||||||
];
|
|
||||||
|
|
||||||
let entries = persisted_design_reasoning_entries(&session);
|
|
||||||
assert_eq!(
|
|
||||||
entries
|
|
||||||
.iter()
|
|
||||||
.map(|entry| (entry.id.as_str(), entry.message_id.as_deref()))
|
|
||||||
.collect::<Vec<_>>(),
|
|
||||||
vec![
|
|
||||||
("r1", Some("turn:response:0")),
|
|
||||||
("r2", Some("turn:response:0")),
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test(flavor = "current_thread")]
|
|
||||||
async fn scripted_design_provider_emits_reasoning_without_persisting_it() {
|
|
||||||
let (_temp, root, _resources) = init_design_project();
|
|
||||||
let mut session = new_design_session("design-fake", "quality");
|
|
||||||
begin_design_turn(&mut session, "turn-reasoning");
|
|
||||||
let mut response = fake_response("reasoning", "正文", Vec::new());
|
|
||||||
response.reasoning = "先分析需求,再组织方案。".into();
|
|
||||||
let _fake = fake_provider::install(vec![Ok(response)], 0);
|
|
||||||
let mut events = Vec::new();
|
|
||||||
let response =
|
|
||||||
request_scripted_design_provider(&root, &mut session, &mut |event| events.push(event))
|
|
||||||
.await
|
|
||||||
.expect("scripted provider");
|
|
||||||
|
|
||||||
let reasoning_events = events
|
|
||||||
.iter()
|
|
||||||
.filter_map(|event| event.reasoning_text.as_deref())
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
assert_eq!(reasoning_events, vec!["", "先分析需求,再组织方案。"]);
|
|
||||||
assert_eq!(response.text, "正文");
|
|
||||||
assert!(session.history.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test(flavor = "current_thread")]
|
|
||||||
async fn scripted_design_provider_retry_clears_previous_reasoning_attempt() {
|
|
||||||
let (_temp, root, _resources) = init_design_project();
|
|
||||||
let mut session = new_design_session("design-fake", "quality");
|
|
||||||
begin_design_turn(&mut session, "turn-reasoning-retry");
|
|
||||||
let mut response = fake_response("reasoning-retry", "重试后的正文", Vec::new());
|
|
||||||
response.reasoning = "重试后的推理".into();
|
|
||||||
let _fake = fake_provider::install(
|
|
||||||
vec![
|
|
||||||
Err(platform_llm::LlmError::Upstream {
|
|
||||||
status_code: 503,
|
|
||||||
message: "busy".into(),
|
|
||||||
}),
|
|
||||||
Ok(response),
|
|
||||||
],
|
|
||||||
1,
|
|
||||||
);
|
|
||||||
let mut events = Vec::new();
|
|
||||||
let response =
|
|
||||||
request_scripted_design_provider(&root, &mut session, &mut |event| events.push(event))
|
|
||||||
.await
|
|
||||||
.expect("scripted retry provider");
|
|
||||||
|
|
||||||
let reasoning_events = events
|
|
||||||
.iter()
|
|
||||||
.filter_map(|event| event.reasoning_text.as_deref())
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
assert_eq!(reasoning_events, vec!["", "", "重试后的推理"]);
|
|
||||||
assert_eq!(response.text, "重试后的正文");
|
|
||||||
assert!(session.history.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test(flavor = "current_thread")]
|
#[tokio::test(flavor = "current_thread")]
|
||||||
async fn fake_provider_walks_five_phases_and_enters_consultant() {
|
async fn fake_provider_walks_five_phases_and_enters_consultant() {
|
||||||
let (_temp, root, resources) = init_design_project();
|
let (_temp, root, resources) = init_design_project();
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
use super::*;
|
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;
|
pub(crate) const MAX_DIRECT_CODEX_REFERENCES: usize = 32;
|
||||||
const MAX_DIRECT_CODEX_REFERENCE_ID_CHARS: usize = 200;
|
const MAX_DIRECT_CODEX_REFERENCE_ID_CHARS: usize = 200;
|
||||||
@@ -133,7 +137,35 @@ fn validate_resource_reference_id(value: &str) -> Result<String, String> {
|
|||||||
Ok(resource_id.to_string())
|
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(
|
fn render_resource_reference_line(
|
||||||
|
root: &Path,
|
||||||
manifest: &GameCreationAppManifest,
|
manifest: &GameCreationAppManifest,
|
||||||
reference: &DirectCodexResourceReference,
|
reference: &DirectCodexResourceReference,
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
@@ -149,6 +181,19 @@ fn render_resource_reference_line(
|
|||||||
.unwrap_or_else(|| asset_display_label(asset));
|
.unwrap_or_else(|| asset_display_label(asset));
|
||||||
let source = sanitize_reference_source(reference.source.as_deref())
|
let source = sanitize_reference_source(reference.source.as_deref())
|
||||||
.unwrap_or_else(|| "unknown".to_string());
|
.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!(
|
Ok(format!(
|
||||||
"- 素材 ID:{resource_id};名称:{label};类型:{};媒体类型:{};项目路径:{local_path};来源:{source}",
|
"- 素材 ID:{resource_id};名称:{label};类型:{};媒体类型:{};项目路径:{local_path};来源:{source}",
|
||||||
asset.kind, asset.media_type
|
asset.kind, asset.media_type
|
||||||
@@ -234,7 +279,7 @@ pub(crate) fn render_direct_codex_references_section(
|
|||||||
for reference in references {
|
for reference in references {
|
||||||
lines.push(match reference {
|
lines.push(match reference {
|
||||||
DirectCodexTurnReference::Resource(reference) => {
|
DirectCodexTurnReference::Resource(reference) => {
|
||||||
render_resource_reference_line(&manifest, reference)?
|
render_resource_reference_line(root, &manifest, reference)?
|
||||||
}
|
}
|
||||||
DirectCodexTurnReference::RuntimeRegion(reference) => {
|
DirectCodexTurnReference::RuntimeRegion(reference) => {
|
||||||
render_runtime_region_reference_line(&manifest, reference)?
|
render_runtime_region_reference_line(&manifest, reference)?
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
-96
@@ -1074,102 +1074,6 @@ pub(in crate::agent) fn remove_platform_art_generation_runtime_state_at(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 一次性兼容:升级前 standalone 槽身份只由 `{outputPath, requireSlices}` 派生,
|
|
||||||
/// 同一项目所有图片类生成共用一个槽;升级后槽身份按精确动作派生,路径随之变化。
|
|
||||||
///
|
|
||||||
/// 若旧槽路径上的账本仍然属于本次精确动作(`agentId` 与 `actionFingerprint` 都与
|
|
||||||
/// 当前上下文一致),就在项目写锁内把它迁移到新身份路径:保留原 `idempotencyKey`
|
|
||||||
/// 与 `operationId`,避免同一精确动作在升级后二次 POST 计费。旧账本属于其他动作时
|
|
||||||
/// 原样保留(不迁移、不删除、不阻塞),由对应动作自己的请求迁移。
|
|
||||||
///
|
|
||||||
/// 返回 `Ok(false)` 表示没有需要迁移的旧账本。任何身份无法安全解释的情形都失败关闭。
|
|
||||||
pub(super) fn adopt_legacy_standalone_platform_art_generation_runtime_state_at(
|
|
||||||
root: &Path,
|
|
||||||
context: &PlatformArtGenerationRuntimeContext,
|
|
||||||
legacy_run_id: &str,
|
|
||||||
) -> Result<bool, String> {
|
|
||||||
if !is_standalone_platform_art_generation_runtime_context(context)
|
|
||||||
|| legacy_run_id == context.run_id
|
|
||||||
|| !is_lowercase_sha256(legacy_run_id.strip_prefix("slot-").unwrap_or_default())
|
|
||||||
{
|
|
||||||
return Ok(false);
|
|
||||||
}
|
|
||||||
// 与账本创建互斥:迁移必须在同一把项目写锁内完成,否则两个调用可能同时把同一份
|
|
||||||
// 旧账本迁移到新路径,或与新建账本互相覆盖。
|
|
||||||
let _claim_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
|
|
||||||
root,
|
|
||||||
"canvas.asset_generate.runtime.claim",
|
|
||||||
)?;
|
|
||||||
if game_creator_agent_runtime_external_generation_exists(
|
|
||||||
root,
|
|
||||||
&context.agent_id,
|
|
||||||
&context.run_id,
|
|
||||||
) {
|
|
||||||
// 新身份账本已经存在:旧账本不属于本次动作的权威状态,保持两边各自的身份。
|
|
||||||
return Ok(false);
|
|
||||||
}
|
|
||||||
let legacy_relative_path =
|
|
||||||
platform_art_generation_runtime_relative_path(&context.agent_id, legacy_run_id);
|
|
||||||
let Some(legacy_state) =
|
|
||||||
read_agent_runtime_json_sidecar_with_max_bytes::<PlatformArtGenerationRuntimeState>(
|
|
||||||
root,
|
|
||||||
&legacy_relative_path,
|
|
||||||
"External Editor 生成账本",
|
|
||||||
PLATFORM_ART_GENERATION_RUNTIME_MAX_BYTES,
|
|
||||||
)?
|
|
||||||
else {
|
|
||||||
return Ok(false);
|
|
||||||
};
|
|
||||||
if legacy_state.agent_id != context.agent_id
|
|
||||||
|| legacy_state.run_id != legacy_run_id
|
|
||||||
|| legacy_state.action_fingerprint != context.action_fingerprint
|
|
||||||
{
|
|
||||||
// 旧槽里是另一个精确动作的账本:它仍归那个动作所有,本次调用不得消费、改写或删除它。
|
|
||||||
return Ok(false);
|
|
||||||
}
|
|
||||||
let legacy_identity = format!("{}:{legacy_run_id}", context.agent_id);
|
|
||||||
let legacy_context = PlatformArtGenerationRuntimeContext {
|
|
||||||
task_id: legacy_identity.clone(),
|
|
||||||
session_id: legacy_identity.clone(),
|
|
||||||
run_id: legacy_run_id.to_string(),
|
|
||||||
action_id: legacy_identity,
|
|
||||||
..context.clone()
|
|
||||||
};
|
|
||||||
let Some(mut migrated) = read_platform_art_generation_runtime_state(root, &legacy_context)?
|
|
||||||
else {
|
|
||||||
return Ok(false);
|
|
||||||
};
|
|
||||||
migrated.run_id = context.run_id.clone();
|
|
||||||
migrated.task_id = context.task_id.clone();
|
|
||||||
migrated.session_id = context.session_id.clone();
|
|
||||||
migrated.action_id = context.action_id.clone();
|
|
||||||
migrated.updated_at = unix_timestamp();
|
|
||||||
write_platform_art_generation_runtime_state(root, &migrated)?;
|
|
||||||
remove_platform_art_generation_runtime_state_at(root, &context.agent_id, legacy_run_id)?;
|
|
||||||
Ok(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
pub(super) fn platform_art_generation_runtime_operation_id_for_test(
|
|
||||||
state: &PlatformArtGenerationRuntimeState,
|
|
||||||
) -> Option<&str> {
|
|
||||||
state.operation_id.as_deref()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
pub(super) fn platform_art_generation_runtime_run_id_for_test(
|
|
||||||
state: &PlatformArtGenerationRuntimeState,
|
|
||||||
) -> &str {
|
|
||||||
&state.run_id
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
pub(super) fn platform_art_generation_runtime_action_fingerprint_for_test(
|
|
||||||
state: &PlatformArtGenerationRuntimeState,
|
|
||||||
) -> &str {
|
|
||||||
&state.action_fingerprint
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) fn write_platform_art_generation_runtime_accepted_for_test(
|
pub(crate) fn write_platform_art_generation_runtime_accepted_for_test(
|
||||||
root: &Path,
|
root: &Path,
|
||||||
|
|||||||
@@ -409,8 +409,6 @@ where
|
|||||||
(self.on_delta)(&platform_llm::LlmStreamDelta {
|
(self.on_delta)(&platform_llm::LlmStreamDelta {
|
||||||
accumulated_text,
|
accumulated_text,
|
||||||
delta_text,
|
delta_text,
|
||||||
accumulated_reasoning: String::new(),
|
|
||||||
reasoning_delta: String::new(),
|
|
||||||
finish_reason,
|
finish_reason,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -501,7 +499,6 @@ mod tests {
|
|||||||
provider: LlmProvider::OpenAiCompatible,
|
provider: LlmProvider::OpenAiCompatible,
|
||||||
model: "interaction-test".to_string(),
|
model: "interaction-test".to_string(),
|
||||||
text: text.to_string(),
|
text: text.to_string(),
|
||||||
reasoning: String::new(),
|
|
||||||
finish_reason: Some("stop".to_string()),
|
finish_reason: Some("stop".to_string()),
|
||||||
response_id: Some("interaction-response".to_string()),
|
response_id: Some("interaction-response".to_string()),
|
||||||
usage: None,
|
usage: None,
|
||||||
|
|||||||
-10
@@ -115,7 +115,6 @@ fn persist_tool_plan_handoff_repair_chain(
|
|||||||
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
||||||
model: llm.model.clone(),
|
model: llm.model.clone(),
|
||||||
text: text.to_string(),
|
text: text.to_string(),
|
||||||
reasoning: String::new(),
|
|
||||||
finish_reason: Some("stop".to_string()),
|
finish_reason: Some("stop".to_string()),
|
||||||
response_id: None,
|
response_id: None,
|
||||||
usage: None,
|
usage: None,
|
||||||
@@ -147,8 +146,6 @@ fn stream_delta(delta_text: &str, accumulated_text: &str) -> platform_llm::LlmSt
|
|||||||
platform_llm::LlmStreamDelta {
|
platform_llm::LlmStreamDelta {
|
||||||
accumulated_text: accumulated_text.to_string(),
|
accumulated_text: accumulated_text.to_string(),
|
||||||
delta_text: delta_text.to_string(),
|
delta_text: delta_text.to_string(),
|
||||||
accumulated_reasoning: String::new(),
|
|
||||||
reasoning_delta: String::new(),
|
|
||||||
finish_reason: None,
|
finish_reason: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1006,7 +1003,6 @@ async fn provider_handoff_identity_drift_closes_lifecycle_without_leaking_respon
|
|||||||
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
||||||
model: old_llm.model.clone(),
|
model: old_llm.model.clone(),
|
||||||
text: private_response.to_string(),
|
text: private_response.to_string(),
|
||||||
reasoning: String::new(),
|
|
||||||
finish_reason: Some("stop".to_string()),
|
finish_reason: Some("stop".to_string()),
|
||||||
response_id: None,
|
response_id: None,
|
||||||
usage: None,
|
usage: None,
|
||||||
@@ -1120,7 +1116,6 @@ async fn tool_plan_handoff_identity_drift_closes_entire_repair_chain_before_remo
|
|||||||
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
||||||
model: old_llm.model.clone(),
|
model: old_llm.model.clone(),
|
||||||
text: text.to_string(),
|
text: text.to_string(),
|
||||||
reasoning: String::new(),
|
|
||||||
finish_reason: Some("stop".to_string()),
|
finish_reason: Some("stop".to_string()),
|
||||||
response_id: None,
|
response_id: None,
|
||||||
usage: None,
|
usage: None,
|
||||||
@@ -1307,7 +1302,6 @@ async fn tool_plan_capacity_gate_runs_before_provider_lifecycle_and_network() {
|
|||||||
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
||||||
model: llm.model.clone(),
|
model: llm.model.clone(),
|
||||||
text: format!("capacity response {loop_iteration}"),
|
text: format!("capacity response {loop_iteration}"),
|
||||||
reasoning: String::new(),
|
|
||||||
finish_reason: Some("stop".to_string()),
|
finish_reason: Some("stop".to_string()),
|
||||||
response_id: None,
|
response_id: None,
|
||||||
usage: None,
|
usage: None,
|
||||||
@@ -1442,7 +1436,6 @@ async fn tool_plan_handoff_durable_control_closes_entire_repair_chain_before_rem
|
|||||||
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
||||||
model: llm.model.clone(),
|
model: llm.model.clone(),
|
||||||
text: text.to_string(),
|
text: text.to_string(),
|
||||||
reasoning: String::new(),
|
|
||||||
finish_reason: Some("stop".to_string()),
|
finish_reason: Some("stop".to_string()),
|
||||||
response_id: None,
|
response_id: None,
|
||||||
usage: None,
|
usage: None,
|
||||||
@@ -1559,7 +1552,6 @@ fn provider_recovery_cleanup_closes_tool_plan_lifecycle_before_removing_handoff(
|
|||||||
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
||||||
model: llm.model.clone(),
|
model: llm.model.clone(),
|
||||||
text: "cleanup handoff".to_string(),
|
text: "cleanup handoff".to_string(),
|
||||||
reasoning: String::new(),
|
|
||||||
finish_reason: Some("stop".to_string()),
|
finish_reason: Some("stop".to_string()),
|
||||||
response_id: None,
|
response_id: None,
|
||||||
usage: None,
|
usage: None,
|
||||||
@@ -1631,7 +1623,6 @@ fn runtime_resume_scans_and_cleans_terminal_tool_plan_handoff() {
|
|||||||
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
||||||
model: llm.model.clone(),
|
model: llm.model.clone(),
|
||||||
text: "terminal handoff".to_string(),
|
text: "terminal handoff".to_string(),
|
||||||
reasoning: String::new(),
|
|
||||||
finish_reason: Some("stop".to_string()),
|
finish_reason: Some("stop".to_string()),
|
||||||
response_id: None,
|
response_id: None,
|
||||||
usage: None,
|
usage: None,
|
||||||
@@ -1727,7 +1718,6 @@ async fn provider_handoff_retry_conflict_preserves_both_sidecars_for_reconciliat
|
|||||||
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
||||||
model: llm.model.clone(),
|
model: llm.model.clone(),
|
||||||
text: "已成功但尚未消费的回复".to_string(),
|
text: "已成功但尚未消费的回复".to_string(),
|
||||||
reasoning: String::new(),
|
|
||||||
finish_reason: Some("stop".to_string()),
|
finish_reason: Some("stop".to_string()),
|
||||||
response_id: None,
|
response_id: None,
|
||||||
usage: None,
|
usage: None,
|
||||||
|
|||||||
@@ -798,7 +798,6 @@ mod provider_reconciliation_diagnostic_tests {
|
|||||||
let response = platform_llm::LlmRunResponse {
|
let response = platform_llm::LlmRunResponse {
|
||||||
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
||||||
model: "test-model".to_string(),
|
model: "test-model".to_string(),
|
||||||
reasoning: String::new(),
|
|
||||||
text: "C:\\private\\response".to_string(),
|
text: "C:\\private\\response".to_string(),
|
||||||
finish_reason: Some("completed".to_string()),
|
finish_reason: Some("completed".to_string()),
|
||||||
response_id: Some("response-1".to_string()),
|
response_id: Some("response-1".to_string()),
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -604,10 +604,10 @@ pub(crate) fn register_local_asset_at(
|
|||||||
register_local_asset_entry(root, local_path, kind, media_type, id_prefix, source)
|
register_local_asset_entry(root, local_path, kind, media_type, id_prefix, source)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn register_design_artifacts_at(root: &Path) -> Result<bool, String> {
|
pub(crate) fn register_design_artifacts_at(root: &Path) -> Result<usize, String> {
|
||||||
let design_root = root.join("design_artifacts");
|
let design_root = root.join("design_artifacts");
|
||||||
if !design_root.exists() {
|
if !design_root.exists() {
|
||||||
return Ok(false);
|
return Ok(0);
|
||||||
}
|
}
|
||||||
let mut files = Vec::new();
|
let mut files = Vec::new();
|
||||||
let mut directories = vec![design_root];
|
let mut directories = vec![design_root];
|
||||||
@@ -630,7 +630,7 @@ pub(crate) fn register_design_artifacts_at(root: &Path) -> Result<bool, String>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
files.sort();
|
files.sort();
|
||||||
let mut changed = false;
|
let mut registered = 0;
|
||||||
for path in files {
|
for path in files {
|
||||||
let relative = path
|
let relative = path
|
||||||
.strip_prefix(root)
|
.strip_prefix(root)
|
||||||
@@ -644,7 +644,7 @@ pub(crate) fn register_design_artifacts_at(root: &Path) -> Result<bool, String>
|
|||||||
Some("yaml" | "yml") => "text/yaml",
|
Some("yaml" | "yml") => "text/yaml",
|
||||||
_ => "application/octet-stream",
|
_ => "application/octet-stream",
|
||||||
};
|
};
|
||||||
let (_, asset_changed) = register_local_asset_entry_with_change(
|
register_local_asset_at(
|
||||||
root,
|
root,
|
||||||
&relative,
|
&relative,
|
||||||
"document",
|
"document",
|
||||||
@@ -663,9 +663,9 @@ pub(crate) fn register_design_artifacts_at(root: &Path) -> Result<bool, String>
|
|||||||
reference_resource_ids: Vec::new(),
|
reference_resource_ids: Vec::new(),
|
||||||
},
|
},
|
||||||
)?;
|
)?;
|
||||||
changed |= asset_changed;
|
registered += 1;
|
||||||
}
|
}
|
||||||
Ok(changed)
|
Ok(registered)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn import_canvas_asset_at(
|
pub(crate) fn import_canvas_asset_at(
|
||||||
@@ -1876,18 +1876,6 @@ pub(crate) fn register_local_asset_entry(
|
|||||||
id_prefix: &str,
|
id_prefix: &str,
|
||||||
source: GameCreationAppAssetSource,
|
source: GameCreationAppAssetSource,
|
||||||
) -> Result<UploadLocalAssetResult, String> {
|
) -> Result<UploadLocalAssetResult, String> {
|
||||||
register_local_asset_entry_with_change(root, local_path, kind, media_type, id_prefix, source)
|
|
||||||
.map(|(result, _)| result)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn register_local_asset_entry_with_change(
|
|
||||||
root: &Path,
|
|
||||||
local_path: &str,
|
|
||||||
kind: &str,
|
|
||||||
media_type: &str,
|
|
||||||
id_prefix: &str,
|
|
||||||
source: GameCreationAppAssetSource,
|
|
||||||
) -> Result<(UploadLocalAssetResult, bool), String> {
|
|
||||||
let normalized_path = normalize_relative_path(local_path)?;
|
let normalized_path = normalize_relative_path(local_path)?;
|
||||||
let absolute_path = resolve_local_project_path(root, &normalized_path)?;
|
let absolute_path = resolve_local_project_path(root, &normalized_path)?;
|
||||||
let manifest_path = root.join(".agent/manifest.json");
|
let manifest_path = root.join(".agent/manifest.json");
|
||||||
@@ -1900,7 +1888,7 @@ fn register_local_asset_entry_with_change(
|
|||||||
let mut source_for_record = source.clone();
|
let mut source_for_record = source.clone();
|
||||||
source_for_record.prompt = None;
|
source_for_record.prompt = None;
|
||||||
|
|
||||||
let (id, record_type, changed) = mutate_manifest_at(root, |manifest| {
|
let (id, record_type) = mutate_manifest_at(root, |manifest| {
|
||||||
if let Some(existing) = manifest
|
if let Some(existing) = manifest
|
||||||
.assets
|
.assets
|
||||||
.iter_mut()
|
.iter_mut()
|
||||||
@@ -1910,16 +1898,13 @@ fn register_local_asset_entry_with_change(
|
|||||||
// 而陈旧的非 unclassified 值会被读侧无条件信任(自愈只在落盘值是 unclassified
|
// 而陈旧的非 unclassified 值会被读侧无条件信任(自愈只在落盘值是 unclassified
|
||||||
// 时才触发),于是这个资产永远停在错误栏目。
|
// 时才触发),于是这个资产永远停在错误栏目。
|
||||||
// kind 没变时刻意不动 category——落盘分类是权威值,同 kind 重登记不得抹掉它。
|
// kind 没变时刻意不动 category——落盘分类是权威值,同 kind 重登记不得抹掉它。
|
||||||
let changed = existing.kind != kind
|
|
||||||
|| existing.media_type != media_type
|
|
||||||
|| existing.source != source;
|
|
||||||
if existing.kind != kind {
|
if existing.kind != kind {
|
||||||
existing.kind = kind.to_string();
|
existing.kind = kind.to_string();
|
||||||
existing.category = game_creation_app_asset_category_for_kind(kind);
|
existing.category = game_creation_app_asset_category_for_kind(kind);
|
||||||
}
|
}
|
||||||
existing.media_type = media_type.to_string();
|
existing.media_type = media_type.to_string();
|
||||||
existing.source = source;
|
existing.source = source;
|
||||||
Ok((existing.id.clone(), "asset.update", changed))
|
Ok((existing.id.clone(), "asset.update"))
|
||||||
} else {
|
} else {
|
||||||
let id = format!(
|
let id = format!(
|
||||||
"{id_prefix}-{}-{}",
|
"{id_prefix}-{}-{}",
|
||||||
@@ -1937,7 +1922,7 @@ fn register_local_asset_entry_with_change(
|
|||||||
tags: Vec::new(),
|
tags: Vec::new(),
|
||||||
source,
|
source,
|
||||||
});
|
});
|
||||||
Ok((id, "asset.register", true))
|
Ok((id, "asset.register"))
|
||||||
}
|
}
|
||||||
})?;
|
})?;
|
||||||
append_agent_db_record(
|
append_agent_db_record(
|
||||||
@@ -1952,15 +1937,12 @@ fn register_local_asset_entry_with_change(
|
|||||||
}),
|
}),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
Ok((
|
Ok(UploadLocalAssetResult {
|
||||||
UploadLocalAssetResult {
|
id,
|
||||||
id,
|
local_path: normalized_path.clone(),
|
||||||
local_path: normalized_path.clone(),
|
absolute_path: absolute_path.to_string_lossy().into_owned(),
|
||||||
absolute_path: absolute_path.to_string_lossy().into_owned(),
|
manifest_path: manifest_path.to_string_lossy().into_owned(),
|
||||||
manifest_path: manifest_path.to_string_lossy().into_owned(),
|
})
|
||||||
},
|
|
||||||
changed,
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
@@ -2155,27 +2137,6 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use std::io::{Read, Write};
|
use std::io::{Read, Write};
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn design_artifact_registration_reports_only_real_manifest_changes() {
|
|
||||||
let temporary = tempfile::tempdir().expect("tempdir");
|
|
||||||
let root = temporary.path();
|
|
||||||
crate::project::init_local_game_project_at(root, "design-artifact-test", "策划产物登记")
|
|
||||||
.expect("init project");
|
|
||||||
fs::create_dir_all(root.join("design_artifacts/project")).expect("create artifacts");
|
|
||||||
fs::write(root.join("design_artifacts/project/design.md"), "设计内容")
|
|
||||||
.expect("write artifact");
|
|
||||||
|
|
||||||
assert!(register_design_artifacts_at(root).expect("register first time"));
|
|
||||||
assert_eq!(
|
|
||||||
read_existing_manifest_for_project(root)
|
|
||||||
.unwrap()
|
|
||||||
.assets
|
|
||||||
.len(),
|
|
||||||
1
|
|
||||||
);
|
|
||||||
assert!(!register_design_artifacts_at(root).expect("register idempotently"));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 画板导出推断出的 kind 必须已经是 canonical 值。
|
/// 画板导出推断出的 kind 必须已经是 canonical 值。
|
||||||
///
|
///
|
||||||
/// 这个值会被原样写进 manifest 并据以派生落盘 `category`;一旦写出非 canonical 值
|
/// 这个值会被原样写进 manifest 并据以派生落盘 `category`;一旦写出非 canonical 值
|
||||||
|
|||||||
@@ -517,7 +517,11 @@ pub(crate) fn create_automatic_local_game_project_at(
|
|||||||
match fs::create_dir(&project_root) {
|
match fs::create_dir(&project_root) {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
let result = (|| {
|
let result = (|| {
|
||||||
harden_new_game_creator_private_path(&project_root, true, "自动项目目录")?;
|
prepare_game_creator_private_path_for_read(
|
||||||
|
&project_root,
|
||||||
|
true,
|
||||||
|
"自动项目目录",
|
||||||
|
)?;
|
||||||
enforce_project_permission_policy(&project_root, "project.create")?;
|
enforce_project_permission_policy(&project_root, "project.create")?;
|
||||||
let _lock = acquire_project_write_lock(&project_root, "project.create")?;
|
let _lock = acquire_project_write_lock(&project_root, "project.create")?;
|
||||||
init_local_game_project_at(
|
init_local_game_project_at(
|
||||||
@@ -2140,7 +2144,10 @@ pub(crate) fn create_ui_design_resource(
|
|||||||
let next_index = manifest
|
let next_index = manifest
|
||||||
.assets
|
.assets
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|asset| asset.kind == "UI")
|
.filter(|asset| {
|
||||||
|
asset.kind == crate::ui_editor::persistence::UI_DESIGN_DOC_ASSET_KIND
|
||||||
|
&& asset.media_type == crate::ui_editor::persistence::UI_DESIGN_DOC_MEDIA_TYPE
|
||||||
|
})
|
||||||
.count()
|
.count()
|
||||||
+ 1;
|
+ 1;
|
||||||
let resource_name = format!("UI 设计 {next_index}");
|
let resource_name = format!("UI 设计 {next_index}");
|
||||||
@@ -2174,8 +2181,8 @@ pub(crate) fn create_ui_design_resource(
|
|||||||
let asset = match register_local_asset_at(
|
let asset = match register_local_asset_at(
|
||||||
root,
|
root,
|
||||||
&relative_path,
|
&relative_path,
|
||||||
"UI",
|
crate::ui_editor::persistence::UI_DESIGN_DOC_ASSET_KIND,
|
||||||
"application/json",
|
crate::ui_editor::persistence::UI_DESIGN_DOC_MEDIA_TYPE,
|
||||||
"generated",
|
"generated",
|
||||||
GameCreationAppAssetSource {
|
GameCreationAppAssetSource {
|
||||||
kind: GameCreationAppAssetSourceKind::Generated,
|
kind: GameCreationAppAssetSourceKind::Generated,
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ fn user_selected_path_grants() -> &'static Mutex<HashMap<String, UserSelectedPat
|
|||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
fn normalize_user_selected_path_key(path: &Path) -> Option<String> {
|
fn normalize_user_selected_path_key(path: &Path) -> Option<String> {
|
||||||
let path = normalize_windows_policy_path(path);
|
|
||||||
if !path.is_absolute()
|
if !path.is_absolute()
|
||||||
|| path
|
|| path
|
||||||
.components()
|
.components()
|
||||||
@@ -43,20 +42,6 @@ fn normalize_user_selected_path_key(path: &Path) -> Option<String> {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(windows)]
|
|
||||||
fn normalize_windows_policy_path(path: &Path) -> PathBuf {
|
|
||||||
let value = path.to_string_lossy();
|
|
||||||
if let Some(rest) = value.strip_prefix(r"\\?\UNC\") {
|
|
||||||
return PathBuf::from(format!(r"\\{rest}"));
|
|
||||||
}
|
|
||||||
PathBuf::from(value.strip_prefix(r"\\?\").unwrap_or(&value))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(windows))]
|
|
||||||
fn normalize_windows_policy_path(path: &Path) -> PathBuf {
|
|
||||||
path.to_path_buf()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
pub(crate) fn register_game_creator_user_selected_path(path: &Path, is_directory: bool) {
|
pub(crate) fn register_game_creator_user_selected_path(path: &Path, is_directory: bool) {
|
||||||
let Some(key) = normalize_user_selected_path_key(path) else {
|
let Some(key) = normalize_user_selected_path_key(path) else {
|
||||||
@@ -853,7 +838,6 @@ pub(crate) fn validate_game_creator_private_path_ancestors(
|
|||||||
/// separate, explicit user-selected scope below covers native picker/project
|
/// separate, explicit user-selected scope below covers native picker/project
|
||||||
/// root results, including projects stored outside the current profile.
|
/// root results, including projects stored outside the current profile.
|
||||||
fn game_creator_private_path_allows_auto_elevation(path: &Path) -> bool {
|
fn game_creator_private_path_allows_auto_elevation(path: &Path) -> bool {
|
||||||
let path = normalize_windows_policy_path(path);
|
|
||||||
if !path.is_absolute()
|
if !path.is_absolute()
|
||||||
|| path
|
|| path
|
||||||
.components()
|
.components()
|
||||||
@@ -862,10 +846,7 @@ fn game_creator_private_path_allows_auto_elevation(path: &Path) -> bool {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
let starts_with_path = |root: &Path| {
|
let starts_with_path = |root: &Path| path == root || path.starts_with(root);
|
||||||
let root = normalize_windows_policy_path(root);
|
|
||||||
path == root || path.starts_with(root)
|
|
||||||
};
|
|
||||||
if game_creator_runtime_config_dir()
|
if game_creator_runtime_config_dir()
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.is_some_and(starts_with_path)
|
.is_some_and(starts_with_path)
|
||||||
@@ -1075,7 +1056,6 @@ pub(crate) fn parse_windows_acl_repair_scope(value: &str) -> Result<WindowsAclRe
|
|||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
fn game_creator_runtime_config_repair_scope(path: &Path) -> WindowsAclRepairScope {
|
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);
|
let is_builtin_root = |root: PathBuf| path == root || path.starts_with(root);
|
||||||
if let Some(home) = std::env::var_os("USERPROFILE")
|
if let Some(home) = std::env::var_os("USERPROFILE")
|
||||||
.or_else(|| std::env::var_os("HOME"))
|
.or_else(|| std::env::var_os("HOME"))
|
||||||
@@ -1319,10 +1299,15 @@ pub(crate) fn ensure_game_creator_private_directory_tree(
|
|||||||
#[cfg(all(windows, test))]
|
#[cfg(all(windows, test))]
|
||||||
initialize_windows_game_creator_directory_owner_for_current_user(&directory)?;
|
initialize_windows_game_creator_directory_owner_for_current_user(&directory)?;
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
// This invocation created the directory: initialize it in
|
if game_creator_private_path_allows_auto_elevation(&directory) {
|
||||||
// process first, with a narrowly-scoped managed-path fallback
|
secure_windows_game_creator_path_for_current_user_with_auto_elevation(
|
||||||
// only if Windows rejects that local ACL update.
|
&directory, true, true,
|
||||||
harden_new_game_creator_private_path(&directory, true, label)?;
|
)?;
|
||||||
|
} else {
|
||||||
|
secure_windows_game_creator_path_for_current_user_with_owner_policy(
|
||||||
|
&directory, true, true, true,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
{
|
{
|
||||||
use std::os::unix::fs::PermissionsExt;
|
use std::os::unix::fs::PermissionsExt;
|
||||||
@@ -1361,7 +1346,15 @@ pub(crate) fn ensure_game_creator_private_directory_tree(
|
|||||||
fs::create_dir(&directory).map_err(|retry_error| {
|
fs::create_dir(&directory).map_err(|retry_error| {
|
||||||
format!("创建 {label} 失败:{}: {retry_error}", directory.display())
|
format!("创建 {label} 失败:{}: {retry_error}", directory.display())
|
||||||
})?;
|
})?;
|
||||||
harden_new_game_creator_private_path(&directory, true, label)?;
|
if game_creator_private_path_allows_auto_elevation(&directory) {
|
||||||
|
secure_windows_game_creator_path_for_current_user_with_auto_elevation(
|
||||||
|
&directory, true, true,
|
||||||
|
)?;
|
||||||
|
} else {
|
||||||
|
secure_windows_game_creator_path_for_current_user_with_owner_policy(
|
||||||
|
&directory, true, true, true,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
@@ -1426,34 +1419,15 @@ pub(crate) fn harden_new_game_creator_private_path(
|
|||||||
path.display()
|
path.display()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
// This invocation created the object, so local hardening is always
|
// This invocation created the object, so its owner is the current
|
||||||
// the first path. Some Windows configurations can nevertheless
|
// user. Tighten the inherited descriptor in-process; UAC repair is
|
||||||
// reject the descriptor update (for example when an inherited ACL is
|
// reserved for existing, externally-owned objects.
|
||||||
// protected by the parent). Only a managed path may use the existing
|
secure_windows_game_creator_path_for_current_user_with_owner_policy(
|
||||||
// one-shot repair in that exceptional case; ordinary new projects do
|
path,
|
||||||
// not prompt for UAC.
|
is_directory,
|
||||||
if let Err(local_error) =
|
true,
|
||||||
secure_windows_game_creator_path_for_current_user_with_owner_policy(
|
true,
|
||||||
path,
|
)?;
|
||||||
is_directory,
|
|
||||||
true,
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
{
|
|
||||||
if !game_creator_private_path_allows_auto_elevation(path)
|
|
||||||
|| !windows_acl_error_may_need_elevation(&local_error)
|
|
||||||
{
|
|
||||||
return Err(local_error);
|
|
||||||
}
|
|
||||||
secure_windows_game_creator_path_for_current_user_with_auto_elevation(
|
|
||||||
path,
|
|
||||||
is_directory,
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
.map_err(|repair_error| {
|
|
||||||
format!("{local_error};新建对象的受控 ACL 修复未完成:{repair_error}")
|
|
||||||
})?;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
{
|
{
|
||||||
@@ -4532,23 +4506,6 @@ mod private_path_elevation_policy_tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(windows)]
|
|
||||||
#[test]
|
|
||||||
fn verbatim_packaged_appdata_path_keeps_managed_repair_scope() {
|
|
||||||
let root = std::env::var_os("LOCALAPPDATA")
|
|
||||||
.or_else(|| std::env::var_os("APPDATA"))
|
|
||||||
.map(PathBuf::from)
|
|
||||||
.expect("local appdata");
|
|
||||||
let packaged = root.join("world.genarrative.ai-game-creator");
|
|
||||||
let verbatim = PathBuf::from(format!(r"\\?\{}", packaged.display()));
|
|
||||||
|
|
||||||
assert!(game_creator_private_path_allows_auto_elevation(&verbatim));
|
|
||||||
assert_eq!(
|
|
||||||
game_creator_runtime_config_repair_scope(&verbatim),
|
|
||||||
WindowsAclRepairScope::Managed
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
#[test]
|
#[test]
|
||||||
fn picker_grant_is_required_and_directory_grant_covers_descendants() {
|
fn picker_grant_is_required_and_directory_grant_covers_descendants() {
|
||||||
|
|||||||
@@ -905,7 +905,6 @@ mod tests {
|
|||||||
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
||||||
model: "context-compaction-test".to_string(),
|
model: "context-compaction-test".to_string(),
|
||||||
text: summary.into(),
|
text: summary.into(),
|
||||||
reasoning: String::new(),
|
|
||||||
finish_reason: Some("stop".to_string()),
|
finish_reason: Some("stop".to_string()),
|
||||||
response_id: Some("context-compaction-response".to_string()),
|
response_id: Some("context-compaction-response".to_string()),
|
||||||
usage: Some(platform_llm::LlmTokenUsage {
|
usage: Some(platform_llm::LlmTokenUsage {
|
||||||
|
|||||||
@@ -26,12 +26,6 @@ pub(crate) struct LocalProjectImagePreview {
|
|||||||
pub(crate) byte_len: u64,
|
pub(crate) byte_len: u64,
|
||||||
pub(crate) pixel_width: u32,
|
pub(crate) pixel_width: u32,
|
||||||
pub(crate) pixel_height: 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,
|
pub(crate) data_url: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,17 +102,12 @@ pub(crate) fn load_local_project_image_preview_with_cancellation(
|
|||||||
false,
|
false,
|
||||||
)?;
|
)?;
|
||||||
cancellation.check()?;
|
cancellation.check()?;
|
||||||
// 头部级 alpha 判据:只读签名与头部标志(PNG 还会按 chunk 头跳过数据体找 `tRNS`),
|
|
||||||
// 不做熵解码、不做逐像素扫描,成本不随像素数增长,因此大图与「AI 把棋盘格画进图里」
|
|
||||||
// 的不透明图都不会因此变慢。
|
|
||||||
let has_alpha = detect_raster_image_has_alpha(&image.bytes, image.media_type);
|
|
||||||
Ok(LocalProjectImagePreview {
|
Ok(LocalProjectImagePreview {
|
||||||
path: image.relative_path.clone(),
|
path: image.relative_path.clone(),
|
||||||
media_type: image.media_type.to_string(),
|
media_type: image.media_type.to_string(),
|
||||||
byte_len: image.byte_len,
|
byte_len: image.byte_len,
|
||||||
pixel_width: image.pixel_width,
|
pixel_width: image.pixel_width,
|
||||||
pixel_height: image.pixel_height,
|
pixel_height: image.pixel_height,
|
||||||
has_alpha,
|
|
||||||
data_url: image.data_url_with_cancellation(cancellation)?,
|
data_url: image.data_url_with_cancellation(cancellation)?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -431,84 +420,6 @@ fn detect_raster_image_dimensions(bytes: &[u8], media_type: &str) -> Option<(u32
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 头部级 alpha 判据:这张图**有没有 alpha 通道 / 透明像素**,只看签名与头部标志
|
|
||||||
/// (PNG 还会按 chunk 头跳过数据体找 `tRNS`)。
|
|
||||||
///
|
|
||||||
/// 为什么必须是头部级而不是像素级:资源卡预览按 8 MiB / 8192 边长 / 3270 万像素上限读取,
|
|
||||||
/// 逐像素扫描意味着对每张卡都做一次全量 RGBA 解码(真机单栏 51 张、单张均值 591 KB),
|
|
||||||
/// 成本与「卡面装饰底」的收益完全不成比例;而 alpha 是否存在在容器头部就是确定信息。
|
|
||||||
///
|
|
||||||
/// 判据(保守方向一致:判不出就当作不透明,宁可不铺棋盘格):
|
|
||||||
/// - PNG:颜色类型 4(灰度 + alpha)/ 6(真彩 + alpha);0 / 2 / 3 本身没有 alpha 通道,
|
|
||||||
/// 但可以用 `tRNS` 声明透明色,因此还要在第一个 `IDAT` 之前找一次 `tRNS`;
|
|
||||||
/// - WebP:扩展格式 `VP8X` 的 flags 第 4 位、无损 `VP8L` 位流头的 `alpha_is_used` 位;
|
|
||||||
/// 简单有损 `VP8 ` 不带 alpha 通道(带 alpha 的有损 WebP 一定走 `VP8X` + `ALPH`);
|
|
||||||
/// - JPEG:没有 alpha 通道,恒不透明(也绝不为了判 alpha 去扫它的段)。
|
|
||||||
fn detect_raster_image_has_alpha(bytes: &[u8], media_type: &str) -> bool {
|
|
||||||
match media_type {
|
|
||||||
"image/png" => detect_png_has_alpha(bytes),
|
|
||||||
"image/webp" => detect_webp_has_alpha(bytes),
|
|
||||||
_ => false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn detect_png_has_alpha(bytes: &[u8]) -> bool {
|
|
||||||
// 签名 8 字节 + IHDR 长度 4 + "IHDR" 4 + 宽 4 + 高 4 + 位深 1 + 颜色类型 1 = 26。
|
|
||||||
if bytes.len() < 26 || &bytes[12..16] != b"IHDR" {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if matches!(bytes[25], 4 | 6) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
png_has_transparency_chunk(bytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 按 chunk 头前进并查找 `tRNS`:只读 8 字节 chunk 头并按长度跳过数据体,不做 zlib 解压。
|
|
||||||
fn png_has_transparency_chunk(bytes: &[u8]) -> bool {
|
|
||||||
let mut offset = 8usize;
|
|
||||||
loop {
|
|
||||||
let Some(header_end) = offset.checked_add(8) else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
if header_end > bytes.len() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
let chunk_type = &bytes[offset + 4..header_end];
|
|
||||||
// `tRNS` 必须出现在第一个 `IDAT` 之前;碰到 `IDAT` / `IEND` 就没有再往下扫的意义。
|
|
||||||
if chunk_type == b"tRNS" {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if chunk_type == b"IDAT" || chunk_type == b"IEND" {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
let chunk_len =
|
|
||||||
u32::from_be_bytes(bytes[offset..offset + 4].try_into().unwrap_or([0_u8; 4])) as usize;
|
|
||||||
let Some(next) = header_end
|
|
||||||
.checked_add(chunk_len)
|
|
||||||
.and_then(|value| value.checked_add(4))
|
|
||||||
else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
if next <= offset || next > bytes.len() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
offset = next;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn detect_webp_has_alpha(bytes: &[u8]) -> bool {
|
|
||||||
if bytes.len() < 16 || &bytes[..4] != b"RIFF" || &bytes[8..12] != b"WEBP" {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
match &bytes[12..16] {
|
|
||||||
// `VP8X` 的 flags 第 4 位(0x10)就是 alpha 标志(第 20 字节)。
|
|
||||||
b"VP8X" => bytes.get(20).is_some_and(|flags| flags & 0x10 != 0),
|
|
||||||
// `VP8L` 位流头第 28 位是 `alpha_is_used`,落在第 25 个字节(下标 24)的 0x10 位。
|
|
||||||
b"VP8L" => bytes.len() >= 25 && bytes[24] & 0x10 != 0,
|
|
||||||
_ => false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
enum TiffByteOrder {
|
enum TiffByteOrder {
|
||||||
LittleEndian,
|
LittleEndian,
|
||||||
@@ -834,74 +745,6 @@ mod tests {
|
|||||||
.expect("valid 1x1 png")
|
.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> {
|
fn jpeg_bytes(width: u16, height: u16, app1_payload: Option<&[u8]>) -> Vec<u8> {
|
||||||
let mut bytes = vec![0xff, 0xd8];
|
let mut bytes = vec![0xff, 0xd8];
|
||||||
if let Some(payload) = app1_payload {
|
if let Some(payload) = app1_payload {
|
||||||
@@ -997,138 +840,6 @@ mod tests {
|
|||||||
assert_eq!(preview.media_type, "image/png");
|
assert_eq!(preview.media_type, "image/png");
|
||||||
assert_eq!(preview.byte_len, png_bytes().len() as u64);
|
assert_eq!(preview.byte_len, png_bytes().len() as u64);
|
||||||
assert!(preview.data_url.starts_with("data:image/png;base64,"));
|
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]
|
#[test]
|
||||||
|
|||||||
@@ -244,7 +244,6 @@ macro_rules! app_log {
|
|||||||
// 用 #[cfg] 编译期门控:仅开发(debug)且非测试构建编入;生产 release 与 cargo test 下整体剔除。
|
// 用 #[cfg] 编译期门控:仅开发(debug)且非测试构建编入;生产 release 与 cargo test 下整体剔除。
|
||||||
mod agent;
|
mod agent;
|
||||||
mod agent_native_tools;
|
mod agent_native_tools;
|
||||||
mod asset_generation_tasks;
|
|
||||||
mod assets;
|
mod assets;
|
||||||
mod browser;
|
mod browser;
|
||||||
mod builtin_plugins;
|
mod builtin_plugins;
|
||||||
@@ -290,7 +289,6 @@ mod windows;
|
|||||||
|
|
||||||
use agent::*;
|
use agent::*;
|
||||||
use agent_native_tools::*;
|
use agent_native_tools::*;
|
||||||
use asset_generation_tasks::*;
|
|
||||||
use assets::*;
|
use assets::*;
|
||||||
use browser::*;
|
use browser::*;
|
||||||
use cli::*;
|
use cli::*;
|
||||||
@@ -2738,8 +2736,6 @@ fn main() {
|
|||||||
ensure_ui_design_resource_for_prototype,
|
ensure_ui_design_resource_for_prototype,
|
||||||
generate_platform_art_asset,
|
generate_platform_art_asset,
|
||||||
generate_local_project_asset,
|
generate_local_project_asset,
|
||||||
start_local_project_asset_generation,
|
|
||||||
list_local_project_asset_generations,
|
|
||||||
open_canvas_project,
|
open_canvas_project,
|
||||||
get_game_creation_agent_capabilities,
|
get_game_creation_agent_capabilities,
|
||||||
get_limited_local_commands,
|
get_limited_local_commands,
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ impl AgentRuntimeProviderHandoffRecord {
|
|||||||
provider: self.response.provider,
|
provider: self.response.provider,
|
||||||
model: self.response.model.clone(),
|
model: self.response.model.clone(),
|
||||||
text: self.response.text.clone(),
|
text: self.response.text.clone(),
|
||||||
reasoning: String::new(),
|
|
||||||
finish_reason: self.response.finish_reason.clone(),
|
finish_reason: self.response.finish_reason.clone(),
|
||||||
response_id: self.response.response_id.clone(),
|
response_id: self.response.response_id.clone(),
|
||||||
usage: self.response.usage.clone(),
|
usage: self.response.usage.clone(),
|
||||||
@@ -341,7 +340,6 @@ mod tests {
|
|||||||
provider: LlmProvider::OpenAiCompatible,
|
provider: LlmProvider::OpenAiCompatible,
|
||||||
model: "handoff-model".to_string(),
|
model: "handoff-model".to_string(),
|
||||||
text: text.to_string(),
|
text: text.to_string(),
|
||||||
reasoning: String::new(),
|
|
||||||
finish_reason: Some("stop".to_string()),
|
finish_reason: Some("stop".to_string()),
|
||||||
response_id: Some("response-handoff".to_string()),
|
response_id: Some("response-handoff".to_string()),
|
||||||
usage: Some(LlmTokenUsage {
|
usage: Some(LlmTokenUsage {
|
||||||
|
|||||||
@@ -2775,7 +2775,6 @@ fn durable_provider_handoff_prevents_shutdown_even_when_corrupt() {
|
|||||||
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
||||||
model: "provider-handoff-runner-test".to_string(),
|
model: "provider-handoff-runner-test".to_string(),
|
||||||
text: "durable final reply".to_string(),
|
text: "durable final reply".to_string(),
|
||||||
reasoning: String::new(),
|
|
||||||
finish_reason: Some("stop".to_string()),
|
finish_reason: Some("stop".to_string()),
|
||||||
response_id: Some("provider-handoff-response".to_string()),
|
response_id: Some("provider-handoff-response".to_string()),
|
||||||
usage: None,
|
usage: None,
|
||||||
|
|||||||
@@ -4475,7 +4475,6 @@ fn real_e2e_tool_plan_checkpoint_response() -> platform_llm::LlmRunResponse {
|
|||||||
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
||||||
model: "real-e2e-checkpoint-model".to_string(),
|
model: "real-e2e-checkpoint-model".to_string(),
|
||||||
text: REAL_E2E_TOOL_PLAN_CHECKPOINT_PRIVATE_RESPONSE.to_string(),
|
text: REAL_E2E_TOOL_PLAN_CHECKPOINT_PRIVATE_RESPONSE.to_string(),
|
||||||
reasoning: String::new(),
|
|
||||||
finish_reason: Some("tool_calls".to_string()),
|
finish_reason: Some("tool_calls".to_string()),
|
||||||
response_id: Some("real-e2e-checkpoint-private-response-id".to_string()),
|
response_id: Some("real-e2e-checkpoint-private-response-id".to_string()),
|
||||||
usage: None,
|
usage: None,
|
||||||
@@ -4721,7 +4720,6 @@ fn agent_tool_plan_llm_response(
|
|||||||
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
||||||
model: "mock-game-model".to_string(),
|
model: "mock-game-model".to_string(),
|
||||||
text: text.into(),
|
text: text.into(),
|
||||||
reasoning: String::new(),
|
|
||||||
finish_reason: Some("tool_calls".to_string()),
|
finish_reason: Some("tool_calls".to_string()),
|
||||||
response_id: Some("response-tool-plan-test".to_string()),
|
response_id: Some("response-tool-plan-test".to_string()),
|
||||||
usage: None,
|
usage: None,
|
||||||
|
|||||||
@@ -2031,8 +2031,8 @@ fn register_local_asset_keeps_explicit_category_when_kind_is_unchanged() {
|
|||||||
let registered = register_local_asset_at(
|
let registered = register_local_asset_at(
|
||||||
&root,
|
&root,
|
||||||
"ui/UI 设计 1.json",
|
"ui/UI 设计 1.json",
|
||||||
"UI",
|
shared_contracts::game_creation_app::GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND,
|
||||||
"application/json",
|
shared_contracts::game_creation_app::GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE,
|
||||||
"ui-workflow",
|
"ui-workflow",
|
||||||
source(),
|
source(),
|
||||||
)
|
)
|
||||||
@@ -2053,8 +2053,8 @@ fn register_local_asset_keeps_explicit_category_when_kind_is_unchanged() {
|
|||||||
register_local_asset_at(
|
register_local_asset_at(
|
||||||
&root,
|
&root,
|
||||||
"ui/UI 设计 1.json",
|
"ui/UI 设计 1.json",
|
||||||
"UI",
|
shared_contracts::game_creation_app::GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND,
|
||||||
"application/json",
|
shared_contracts::game_creation_app::GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE,
|
||||||
"ui-workflow",
|
"ui-workflow",
|
||||||
source(),
|
source(),
|
||||||
)
|
)
|
||||||
@@ -2063,7 +2063,10 @@ fn register_local_asset_keeps_explicit_category_when_kind_is_unchanged() {
|
|||||||
let manifest: Value =
|
let manifest: Value =
|
||||||
serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap())
|
serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap())
|
||||||
.expect("manifest json");
|
.expect("manifest json");
|
||||||
assert_eq!(manifest["assets"][0]["kind"], "UI");
|
assert_eq!(
|
||||||
|
manifest["assets"][0]["kind"],
|
||||||
|
shared_contracts::game_creation_app::GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND
|
||||||
|
);
|
||||||
assert_eq!(manifest["assets"][0]["category"], "audio");
|
assert_eq!(manifest["assets"][0]["category"], "audio");
|
||||||
assert_eq!(manifest["assets"][0]["tags"], serde_json::json!(["界面"]));
|
assert_eq!(manifest["assets"][0]["tags"], serde_json::json!(["界面"]));
|
||||||
|
|
||||||
@@ -2074,7 +2077,7 @@ fn register_local_asset_keeps_explicit_category_when_kind_is_unchanged() {
|
|||||||
///
|
///
|
||||||
/// 这里的字面量与写入侧逐字一致:UI 设计资产是
|
/// 这里的字面量与写入侧逐字一致:UI 设计资产是
|
||||||
/// `ui_editor/resource_bridge.rs` / `workflow.rs` / `persistence.rs` 的
|
/// `ui_editor/resource_bridge.rs` / `workflow.rs` / `persistence.rs` 的
|
||||||
/// `register_local_asset_at(root, path, "UI", "application/json", ...)`,
|
/// `register_local_asset_at` 使用 UI 文档 kind/media 常量,
|
||||||
/// 字体是 `commands.rs` 字体上传的 `register_local_asset_entry(root, path, "font", ...)`。
|
/// 字体是 `commands.rs` 字体上传的 `register_local_asset_entry(root, path, "font", ...)`。
|
||||||
/// 只要别名表漏掉它们,真机资产就会永远停在「待归类」且读时自愈也救不回来。
|
/// 只要别名表漏掉它们,真机资产就会永远停在「待归类」且读时自愈也救不回来。
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2099,8 +2102,8 @@ fn register_local_asset_derives_category_from_real_write_side_kinds() {
|
|||||||
register_local_asset_at(
|
register_local_asset_at(
|
||||||
&root,
|
&root,
|
||||||
"ui/UI 设计 1.json",
|
"ui/UI 设计 1.json",
|
||||||
"UI",
|
shared_contracts::game_creation_app::GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND,
|
||||||
"application/json",
|
shared_contracts::game_creation_app::GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE,
|
||||||
"ui-workflow",
|
"ui-workflow",
|
||||||
source(),
|
source(),
|
||||||
)
|
)
|
||||||
@@ -2133,7 +2136,11 @@ fn register_local_asset_derives_category_from_real_write_side_kinds() {
|
|||||||
assert_eq!(
|
assert_eq!(
|
||||||
categories,
|
categories,
|
||||||
vec![
|
vec![
|
||||||
("UI".to_string(), "ui-interaction".to_string()),
|
(
|
||||||
|
shared_contracts::game_creation_app::GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND
|
||||||
|
.to_string(),
|
||||||
|
"ui-interaction".to_string(),
|
||||||
|
),
|
||||||
("font".to_string(), "document".to_string()),
|
("font".to_string(), "document".to_string()),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -127,7 +127,6 @@ impl AgentRuntimeToolPlanHandoffEntry {
|
|||||||
provider: self.response.provider,
|
provider: self.response.provider,
|
||||||
model: self.response.model.clone(),
|
model: self.response.model.clone(),
|
||||||
text,
|
text,
|
||||||
reasoning: String::new(),
|
|
||||||
finish_reason: self.response.finish_reason.clone(),
|
finish_reason: self.response.finish_reason.clone(),
|
||||||
response_id: self.response.response_id.clone(),
|
response_id: self.response.response_id.clone(),
|
||||||
usage: self.response.usage.as_ref().map(LlmTokenUsage::from),
|
usage: self.response.usage.as_ref().map(LlmTokenUsage::from),
|
||||||
|
|||||||
@@ -84,7 +84,6 @@ fn response(text: &str, tool_calls: Vec<LlmToolCall>) -> LlmRunResponse {
|
|||||||
provider: LlmProvider::OpenAiCompatible,
|
provider: LlmProvider::OpenAiCompatible,
|
||||||
model: "tool-plan-handoff-model".to_string(),
|
model: "tool-plan-handoff-model".to_string(),
|
||||||
text: text.to_string(),
|
text: text.to_string(),
|
||||||
reasoning: String::new(),
|
|
||||||
finish_reason: Some("tool_calls".to_string()),
|
finish_reason: Some("tool_calls".to_string()),
|
||||||
response_id: Some("tool-plan-handoff-response".to_string()),
|
response_id: Some("tool-plan-handoff-response".to_string()),
|
||||||
usage: Some(LlmTokenUsage {
|
usage: Some(LlmTokenUsage {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user