Merge remote-tracking branch 'origin/master' into feat/chat-codex-ui
# Conflicts: # apps/ai-game-creator-shell/src/App.tsx
This commit is contained in:
@@ -2477,6 +2477,12 @@ fn direct_game_sources_referenced_taonier_assets(root: &Path) -> Vec<String> {
|
||||
.map(|asset| asset.local_path),
|
||||
);
|
||||
}
|
||||
// Ordinary platform images generated by `agc_generate_image` are valid
|
||||
// runtime art even when the project does not contain the canonical
|
||||
// art-spec/background/spritesheet package. The old check only admitted
|
||||
// those canonical paths, so a game using registered images such as
|
||||
// `assets/neon-mine.png` was forced through repeated repair turns forever.
|
||||
available_paths.extend(direct_registered_taonier_runtime_image_paths(root));
|
||||
available_paths.sort();
|
||||
available_paths.dedup();
|
||||
available_paths
|
||||
@@ -2485,6 +2491,34 @@ fn direct_game_sources_referenced_taonier_assets(root: &Path) -> Vec<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn direct_registered_taonier_runtime_image_paths(root: &Path) -> Vec<String> {
|
||||
let Ok(manifest) = read_manifest_for_project(root) else {
|
||||
return Vec::new();
|
||||
};
|
||||
manifest
|
||||
.assets
|
||||
.iter()
|
||||
.filter(|asset| {
|
||||
asset.media_type.starts_with("image/")
|
||||
&& asset.kind != "icon-spec"
|
||||
&& asset.source.kind == GameCreationAppAssetSourceKind::Canvas
|
||||
&& asset
|
||||
.source
|
||||
.generation_route
|
||||
.as_deref()
|
||||
.is_some_and(|route| route.starts_with("/api/external/v1/editor/"))
|
||||
})
|
||||
.filter_map(|asset| {
|
||||
let path = direct_normalized_project_asset_path(&asset.local_path)?;
|
||||
let bytes = std::fs::read(root.join(&path)).ok()?;
|
||||
let validated =
|
||||
validate_platform_art_png_bytes_with_limits(&bytes, &format!("平台素材 {path}"))
|
||||
.ok()?;
|
||||
validated.has_visible_pixels.then_some(path)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn direct_game_sources_reference_taonier_art_package(root: &Path) -> bool {
|
||||
!direct_game_sources_referenced_taonier_assets(root).is_empty()
|
||||
}
|
||||
@@ -2540,11 +2574,21 @@ fn direct_browser_evidence_needs_art_repair(
|
||||
let Some(evidence) = evidence else {
|
||||
return false;
|
||||
};
|
||||
let referenced_count = direct_game_sources_referenced_taonier_assets(root).len();
|
||||
evidence.passed
|
||||
&& evidence
|
||||
.viewport_results
|
||||
.iter()
|
||||
.any(|viewport| direct_rendered_taonier_assets_in_viewport(root, viewport).is_empty())
|
||||
&& evidence.viewport_results.iter().any(|viewport| {
|
||||
let exact_matches = direct_rendered_taonier_assets_in_viewport(root, viewport);
|
||||
if !exact_matches.is_empty() {
|
||||
return false;
|
||||
}
|
||||
// The preview server rewrites local asset URLs to opaque UUID
|
||||
// routes. When the browser probe cannot map those routes back to
|
||||
// project-relative paths, use its count as a bounded observation:
|
||||
// every referenced platform image must have a corresponding
|
||||
// rendered local-image route in this viewport.
|
||||
let rendered_count = direct_browser_rendered_image_paths(viewport).len();
|
||||
referenced_count == 0 || rendered_count < referenced_count
|
||||
})
|
||||
}
|
||||
|
||||
/// The only output-shape proof the direct Runtime owns. It deliberately does
|
||||
@@ -3914,7 +3958,16 @@ fn render_direct_browser_acceptance_report(
|
||||
};
|
||||
let assets = direct_rendered_taonier_assets_in_viewport(root, viewport);
|
||||
if assets.is_empty() {
|
||||
format!("{name}: 未观察到平台素材进入 Canvas/WebGL 渲染")
|
||||
let rendered_count = direct_browser_rendered_image_paths(viewport).len();
|
||||
if rendered_count > 0
|
||||
&& !direct_game_sources_referenced_taonier_assets(root).is_empty()
|
||||
{
|
||||
format!(
|
||||
"{name}: 已观察到 {rendered_count} 个本地图片资源进入 Canvas/WebGL 渲染"
|
||||
)
|
||||
} else {
|
||||
format!("{name}: 未观察到平台素材进入 Canvas/WebGL 渲染")
|
||||
}
|
||||
} else {
|
||||
format!("{name}: {}", assets.join("、"))
|
||||
}
|
||||
@@ -8183,6 +8236,28 @@ mod tests {
|
||||
.is_some_and(|error| error.contains("未在源码中引用任何已登记的陶泥儿平台图片")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_completion_accepts_an_independent_registered_platform_image() {
|
||||
let root = tempfile::tempdir().expect("temp dir");
|
||||
init_local_game_project_at(root.path(), "direct-independent-image", "独立平台图片")
|
||||
.expect("init project");
|
||||
std::fs::create_dir_all(root.path().join("assets")).expect("assets dir");
|
||||
register_direct_taonier_art_asset_fixture(root.path(), "assets/neon-mine.png", "image");
|
||||
std::fs::write(root.path().join("game/index.html"), "<!doctype html>").expect("index");
|
||||
std::fs::write(root.path().join("game/style.css"), "body {} ").expect("style");
|
||||
std::fs::write(
|
||||
root.path().join("game/game.js"),
|
||||
"const mine = new Image(); mine.src = '/assets/neon-mine.png';",
|
||||
)
|
||||
.expect("script");
|
||||
|
||||
assert_eq!(
|
||||
direct_game_sources_referenced_taonier_assets(root.path()),
|
||||
vec!["assets/neon-mine.png".to_string()]
|
||||
);
|
||||
assert!(direct_game_output_completion_error(root.path()).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_completion_accepts_a_registered_independent_slice_reference() {
|
||||
let root = tempfile::tempdir().expect("temp dir");
|
||||
|
||||
@@ -1914,6 +1914,9 @@ async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Val
|
||||
.await
|
||||
.map_err(|error| format!("抠图服务响应无法解析:{error}"))?;
|
||||
if !status.is_success() {
|
||||
if status == reqwest::StatusCode::UNAUTHORIZED {
|
||||
return Err("authentication-required: 抠图服务提交失败:HTTP 401".to_string());
|
||||
}
|
||||
return Err(format!("抠图服务提交失败:HTTP {}", status.as_u16()));
|
||||
}
|
||||
let queue_state = external_editor_response_data(&payload).clone();
|
||||
|
||||
@@ -868,6 +868,12 @@ pub(crate) async fn external_editor_json_request(
|
||||
/// entire response body (which may contain URLs, ids, paths or credentials).
|
||||
fn format_external_http_error(action: &str, status: reqwest::StatusCode, body: &str) -> String {
|
||||
let detail = summarize_external_http_error_body(body);
|
||||
if status == reqwest::StatusCode::UNAUTHORIZED {
|
||||
return match detail {
|
||||
Some(detail) => format!("authentication-required: {action}失败:HTTP 401:{detail}"),
|
||||
None => format!("authentication-required: {action}失败:HTTP 401"),
|
||||
};
|
||||
}
|
||||
match detail {
|
||||
Some(detail) => format!("{action}失败:HTTP {}:{detail}", status.as_u16()),
|
||||
None => format!("{action}失败:HTTP {}", status.as_u16()),
|
||||
@@ -7930,6 +7936,16 @@ mod canvas_generation_tests {
|
||||
assert!(!summary.contains("C:\\Users\\private"), "{summary}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_http_401_is_classified_as_authentication_required() {
|
||||
let error =
|
||||
format_external_http_error("提交平台图片生成", reqwest::StatusCode::UNAUTHORIZED, "");
|
||||
assert_eq!(
|
||||
error,
|
||||
"authentication-required: 提交平台图片生成失败:HTTP 401"
|
||||
);
|
||||
}
|
||||
|
||||
fn read_test_http_request(stream: &mut std::net::TcpStream) -> String {
|
||||
stream
|
||||
.set_nonblocking(false)
|
||||
|
||||
@@ -311,6 +311,11 @@ const DIRECT_CODEX_RECOVERED_TURN_STALLED_NOTICE =
|
||||
'该回合已无响应,可在输入盒点「终止」结束它以继续';
|
||||
const DIRECT_CODEX_RECOVERED_TURN_STARTED_DETAIL =
|
||||
'已恢复正在运行的回合,正在等待陶泥儿的最新进度';
|
||||
// Platform access tokens are short lived. DirectProject can spend several
|
||||
// minutes in image generation, build and browser validation, so keep the
|
||||
// client-owned native session current while a turn is running. The singleflight
|
||||
// refresh in platformSession.ts coalesces this with any 401-triggered refresh.
|
||||
const DIRECT_CODEX_SESSION_KEEPALIVE_MS = 5 * 60 * 1000;
|
||||
|
||||
function isDirectCodexAuthenticationRequired(error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
@@ -2251,6 +2256,19 @@ export function App({
|
||||
};
|
||||
}, [directCodexProductRuntime]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!directCodexProductRuntime || !chatAgentBusy) {
|
||||
return;
|
||||
}
|
||||
const timer = window.setInterval(() => {
|
||||
void requestPlatformSessionRefresh().catch(() => {
|
||||
// The active DirectProject turn will surface the original auth error;
|
||||
// keepalive must not replace it with an unrelated background error.
|
||||
});
|
||||
}, DIRECT_CODEX_SESSION_KEEPALIVE_MS);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [chatAgentBusy, directCodexProductRuntime]);
|
||||
|
||||
useEffect(() => {
|
||||
if (projectSupervisorOnly && !directCodexProductRuntime) {
|
||||
return;
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# AI 游戏创作智能体 App 实施计划
|
||||
|
||||
## 2026-09-15 DirectProject 长回合平台会话保活
|
||||
|
||||
DirectProject 的生图、素材处理、构建和试玩可能跨越短生命周期 access token 的有效期。普通 `/api/*` 请求和 Codex app-server 已有 401 刷新路径,但 AGC 工具由 Rust 工具桥直接使用客户端当前会话,工具内部的 401 不会自动触发前端刷新。客户端在 DirectProject 回合处于 busy 状态时每 5 分钟调用现有 `requestPlatformSessionRefresh()`;刷新仍复用单飞请求、generation 校验和 native session 安装,不改变凭据来源,也不把 401 降级为成功。刷新失败保持静默,由原始 AGC 工具错误按现有鉴权失败合同返回,避免后台保活覆盖真实错误。
|
||||
|
||||
完成门禁同时允许已登记的普通平台图片作为运行时素材。此前只把 canonical art-spec、背景、图集和图集切片加入来源白名单;`agc_generate_image` 生成的 `assets/neon-*.png` 即使已经登记并被源码引用,也会被判成“未引用平台图片”,触发同一回合的重复修复。浏览器预览把本地图片 URL 改写成 UUID 路径时,验收按每个视口的已渲染本地图片数量与源码引用数量做有界匹配;仍要求两个视口都有对应观察,空视口继续进入修复。
|
||||
|
||||
## 2026-09-12 已有项目打开响应性
|
||||
|
||||
DirectProject 工作区只恢复自身对话,不按专业 Agent 默认任务占位行批量读取旧会话或生成专业 Agent 文本回执。专业 Agent 结果加载 effect 必须以当前 Runtime 模式为边界,并在模式切换时清空旧结果。仍供开发入口使用的 `read_local_conversation` 在 blocking worker 内完整执行权限校验、会话目录解析和历史读取,避免文件访问或锁等待阻塞 Tauri 窗口线程。
|
||||
|
||||
@@ -4517,6 +4517,21 @@ if (
|
||||
for (const line of describeError(error)) {
|
||||
console.error(`[database-backup] ${line}`);
|
||||
}
|
||||
// 即使初始化失败(例如已有进程持有锁),也保持 --result-file 可被机器读取。
|
||||
// 调用方可以检查错误载荷并跳过延后上传,避免对空的临时文件执行 JSON.parse。
|
||||
try {
|
||||
const parsed = parseArgs(process.argv.slice(2));
|
||||
if (parsed.resultFile) {
|
||||
atomicWriteJson(resolvePath(parsed.resultFile), {
|
||||
uploadStatus: 'failed',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
archivePath: '',
|
||||
manifestPath: '',
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// 参数解析或状态文件写入失败时,不能掩盖原始备份错误。
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -519,7 +519,9 @@ prepare_async_backup() {
|
||||
restart_service_args+=(--restart-service-after genarrative-api.service)
|
||||
fi
|
||||
|
||||
ASYNC_BACKUP_STATUS_FILE="$(mktemp /tmp/genarrative-stdb-backup-status.XXXXXX.json)"
|
||||
task_tmp_dir="${HOME}/data/tmp"
|
||||
mkdir -p "${task_tmp_dir}"
|
||||
ASYNC_BACKUP_STATUS_FILE="$(mktemp "${task_tmp_dir}/genarrative-stdb-backup-status.XXXXXX.json")"
|
||||
echo "[production-stdb-publish] publish 前生成本地冷备份,随后会异步上传 OSS"
|
||||
node -- "${ASYNC_BACKUP_SCRIPT}" \
|
||||
--env-file /etc/genarrative/api-server.env \
|
||||
@@ -536,14 +538,32 @@ start_async_backup_upload() {
|
||||
local node_binary=""
|
||||
local unit_name=""
|
||||
local unit_suffix=""
|
||||
local backup_paths=""
|
||||
|
||||
if [[ -z "${ASYNC_BACKUP_STATUS_FILE}" || ! -f "${ASYNC_BACKUP_STATUS_FILE}" ]]; then
|
||||
echo "[production-stdb-publish] 警告:未找到可上传的本地备份状态文件,跳过异步上传" >&2
|
||||
return 0
|
||||
fi
|
||||
|
||||
ASYNC_BACKUP_ARCHIVE="$(node -e 'const fs=require("node:fs"); const p=process.argv[1]; const o=JSON.parse(fs.readFileSync(p,"utf8")); process.stdout.write(o.archivePath || "");' "${ASYNC_BACKUP_STATUS_FILE}")"
|
||||
ASYNC_BACKUP_MANIFEST="$(node -e 'const fs=require("node:fs"); const p=process.argv[1]; const o=JSON.parse(fs.readFileSync(p,"utf8")); process.stdout.write(o.manifestPath || "");' "${ASYNC_BACKUP_STATUS_FILE}")"
|
||||
# 备份进程可能因数据库锁冲突在写入状态文件前退出,或只留下空/截断文件。
|
||||
# 解析失败时保留状态文件并让调用方继续处理发布失败,不能再让 node JSON.parse
|
||||
# 的堆栈噪声覆盖原始备份错误。
|
||||
if ! backup_paths="$(node -e '
|
||||
const fs = require("node:fs");
|
||||
const p = process.argv[1];
|
||||
let o;
|
||||
try {
|
||||
o = JSON.parse(fs.readFileSync(p, "utf8"));
|
||||
} catch {
|
||||
process.exit(2);
|
||||
}
|
||||
process.stdout.write(`${o.archivePath || ""}\n${o.manifestPath || ""}`);
|
||||
' "${ASYNC_BACKUP_STATUS_FILE}" 2>/dev/null)"; then
|
||||
echo "[production-stdb-publish] 警告:备份状态文件为空或不是有效 JSON,跳过异步上传并保留状态文件: ${ASYNC_BACKUP_STATUS_FILE}" >&2
|
||||
return 1
|
||||
fi
|
||||
ASYNC_BACKUP_ARCHIVE="${backup_paths%%$'\n'*}"
|
||||
ASYNC_BACKUP_MANIFEST="${backup_paths#*$'\n'}"
|
||||
if [[ -z "${ASYNC_BACKUP_ARCHIVE}" || -z "${ASYNC_BACKUP_MANIFEST}" ]]; then
|
||||
echo "[production-stdb-publish] 警告:备份状态文件缺少 archivePath 或 manifestPath,跳过异步上传" >&2
|
||||
return 0
|
||||
@@ -727,7 +747,9 @@ if [[ -n "${RUN_AS_USER}" && "$(id -u)" -eq 0 ]]; then
|
||||
echo "[production-stdb-publish] 发布用户不存在: ${RUN_AS_USER}" >&2
|
||||
exit 1
|
||||
fi
|
||||
PUBLISH_TMP_DIR="$(mktemp -d /tmp/genarrative-stdb-publish.XXXXXX)"
|
||||
task_tmp_dir="${HOME}/data/tmp"
|
||||
mkdir -p "${task_tmp_dir}"
|
||||
PUBLISH_TMP_DIR="$(mktemp -d "${task_tmp_dir}/genarrative-stdb-publish.XXXXXX")"
|
||||
install -m 0644 "${SOURCE_DIR}/spacetime_module.wasm" "${PUBLISH_TMP_DIR}/spacetime_module.wasm"
|
||||
chown -R "${RUN_AS_USER}:${RUN_AS_USER}" "${PUBLISH_TMP_DIR}"
|
||||
PUBLISH_ARGS=(
|
||||
|
||||
Reference in New Issue
Block a user