Compare commits

..

27 Commits

Author SHA1 Message Date
suzmii 8a47cdfc1b Merge remote-tracking branch 'origin/master' into feat/game-agent-canvas-resource-workbench-v2
Project CI / Native shell tests (pull_request) Failing after 15m58s
Project CI / Frontend tests (pull_request) Successful in 3m48s
Project CI / Backend tests (pull_request) Successful in 8m0s
Project CI / Repository checks (pull_request) Successful in 11m18s
2026-09-08 19:48:37 +08:00
suzmii 5550a7b28c 修复资源画布文案重命名后的CI失败
删除project-development.suite.ts中未使用的resetButton变量,修复ESLint unused-vars错误

将projectResourceLiveIntegration.test.tsx中过期的复位资源画布断言更新为复位资源视图
2026-09-08 16:38:49 +08:00
suzmii 7bd67f2b3a Merge remote-tracking branch 'origin/master' into feat/game-agent-canvas-resource-workbench-v2 2026-09-08 14:58:58 +08:00
suzmii ea71540413 Merge branch 'feat/game-agent-canvas-resource-workbench-v2' of https://git.genarrative.world/git/GenarrativeAI/Genarrative into feat/game-agent-canvas-resource-workbench-v2 2026-09-08 14:58:29 +08:00
suzmii 4e0ce1902b 建立AGC资源语义类型契约
共享契约新增 canonical 资源类型目录和历史类型读时映射

角色动画派生结果写入 character-animation 主类型

补充角色动画落盘与 legacy 类型映射回归测试

更新资源自由画板技术方案的类型边界说明
2026-09-08 13:41:55 +08:00
suzmii e2a50088c9 修复资源依赖本地身份解析
资源依赖图同时索引远端资源身份和 local-asset 本地资源身份

本地 canonical 引用与远端候选去重后再建立依赖连线

补充本地身份解析与候选不歧义回归测试

更新资源自由画板技术方案的依赖身份合同
2026-09-08 13:29:08 +08:00
suzmii 6de536ecbd Merge branch 'master' into feat/game-agent-canvas-resource-workbench-v2
Project CI / Repository checks (pull_request) Failing after 1m23s
Project CI / Native shell tests (pull_request) Failing after 4m7s
Project CI / Backend tests (pull_request) Successful in 6m28s
Project CI / Frontend tests (pull_request) Failing after 2m7s
2026-09-08 12:24:11 +08:00
suzmii 1347b4a743 去除资源区滚轮切换栏目
Project CI / Frontend tests (pull_request) Failing after 1m49s
Project CI / Native shell tests (pull_request) Failing after 4m32s
Project CI / Repository checks (pull_request) Failing after 9s
Project CI / Backend tests (pull_request) Failing after 9s
删除普通滚轮切页状态、队列与节流控制逻辑

资源区普通滚轮只平移当前视图,缩放继续由 Ctrl 或 Cmd 配合滚轮触发

同步更新 PRD、技术方案与控制器测试
2026-09-08 12:12:29 +08:00
suzmii d5411eae62 去除资源区主子画布概念文案
资源总览、栏目入口、返回与复位文案移除主画布和子画布概念

资源栏目可访问名称改为设计文档、美术资源等名称

同步更新相关界面测试断言
2026-09-08 12:11:09 +08:00
suzmii 0a5080dbd9 Merge remote-tracking branch 'origin/master' into feat/game-agent-canvas-resource-workbench-v2
Project CI / Repository checks (pull_request) Successful in 2m27s
Project CI / Native shell tests (pull_request) Successful in 18m40s
Project CI / Frontend tests (pull_request) Successful in 2m55s
Project CI / Backend tests (pull_request) Successful in 6m32s
2026-09-08 10:45:07 +08:00
suzmii c5bbb864f5 修复资源分页类别缓存回归
资源分页复用完整资源签名以识别同数量类别变化

补充同长度类别变化回归测试
2026-09-07 16:26:08 +08:00
suzmii ccb3a3ab6a 修复资源保存后的自动聚焦
Project CI / Frontend tests (pull_request) Successful in 3m21s
Project CI / Repository checks (pull_request) Successful in 6m18s
Project CI / Backend tests (pull_request) Successful in 6m46s
Project CI / Native shell tests (pull_request) Successful in 17m19s
将保存后资源卡的 DOM 查找范围改为资源画本管理器\n确保新派生资源和恢复资源在布局就绪后能够进入详情聚焦
2026-09-05 13:04:54 +08:00
suzmii 879e732b04 完善资源分页依赖
Project CI / Frontend tests (pull_request) Failing after 2m32s
Project CI / Repository checks (pull_request) Successful in 2m54s
Project CI / Native shell tests (pull_request) Failing after 6m19s
Project CI / Backend tests (pull_request) Successful in 6m39s
按资源类别签名刷新分页类别\n避免资源数量不变时使用过期类别,同时保持稳定引用避免无关编辑面板重置
2026-09-05 12:20:47 +08:00
suzmii cac3e19632 修复资源分页类别缓存
Project CI / Frontend tests (pull_request) Failing after 2m47s
Project CI / Repository checks (pull_request) Successful in 3m4s
Project CI / Native shell tests (pull_request) Failing after 5m49s
Project CI / Backend tests (pull_request) Successful in 7m12s
资源数组变化时重新计算资源分页类别\n避免资源总数不变但类别更新后继续使用旧分页状态
2026-09-05 11:59:23 +08:00
suzmii cd17ae8123 修复资源主画布无限平移与滚轮交互
分离固定交互视口与可平移缩放的内容层
支持主画布滚轮平移并保留缩放及主子画布视口记忆
补充越界拖动和滚轮回归测试并同步技术文档
2026-09-05 11:41:16 +08:00
suzmii 41c9ee7db4 Merge branch 'master' into feat/game-agent-canvas-resource-workbench-v2
Project CI / Frontend tests (pull_request) Failing after 2m23s
Project CI / Repository checks (pull_request) Successful in 2m34s
Project CI / Native shell tests (pull_request) Failing after 4m58s
Project CI / Backend tests (pull_request) Successful in 6m51s
2026-09-05 11:23:18 +08:00
suzmii ba718e3d4b 重构资源画本独立转场与中断处理
Project CI / Repository checks (pull_request) Failing after 15s
Project CI / Backend tests (pull_request) Failing after 15s
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
使用独立FLIP快照层和Web Animations统一动画收尾
统一分类导航并保留滚轮节流和各画布视口
修复快照缩放与悬停导致的终点偏移
补充动画回归测试并更新资源画本技术文档
2026-09-05 11:21:17 +08:00
suzmii 65ffd4cc69 取消资源画本返回主画布的二次平移动画
新增 returning-main 阶段,区分返回主画布与进入子画布的 transform 过渡。

返回期间取消残留分页事件和进行中的子画布切换,避免返回流程被重新改写。

补充状态机与 appSurface 回归测试,并同步技术方案文档。
2026-09-05 09:25:52 +08:00
suzmii 5bcaca7233 重构资源画本画布与转场状态
统一主画布与子画布的可见渲染层和 viewport 状态管理。

将子画布改为固定视口加无限平移 world,并保证首次 fit 的可读比例。

补充转场、滚轮、viewport 和资源画本回归测试,更新技术方案文档。
2026-09-05 09:10:56 +08:00
suzmii 820fbfd988 修复子画布缩放并支持Ctrl滚轮与右下角缩放控件
美术资源初始适配不再放大过头

scene层支持Ctrl滚轮锚点缩放,普通滚轮保留翻页
2026-09-04 16:41:39 +08:00
suzmii 069248b296 子画布展开改用真实坐标与视口承载
资源卡拖拽使用模型坐标换算,消除阻力和回弹

子画布空白区域支持画布平移,保持进入退出动画连贯
2026-09-04 16:31:50 +08:00
suzmii 9c85a9501e 调整资源画本hover高亮仅用于未展开主画布
Project CI / Repository checks (pull_request) Failing after 2m58s
Project CI / Frontend tests (pull_request) Failing after 2m33s
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
子画布展开态不再显示hover高亮线

未展开缩略卡hover时标题栏三边高亮,底边接缝保持默认色
2026-09-04 15:46:42 +08:00
suzmii 816844065b 拆分子画布标题与本体hover高亮
Project CI / Repository checks (pull_request) Failing after 2m52s
Project CI / Frontend tests (pull_request) Failing after 2m17s
Project CI / Native shell tests (pull_request) Failing after 6m15s
Project CI / Backend tests (pull_request) Successful in 6m37s
标题栏与本体分别作为独立对象绑定同一hover源

标题栏底边与本体顶边接缝保持默认色不高亮
2026-09-04 15:42:16 +08:00
suzmii 8323b13fd3 Merge branch 'master' into feat/game-agent-canvas-resource-workbench-v2
Project CI / Frontend tests (pull_request) Failing after 2m29s
Project CI / Repository checks (pull_request) Failing after 2m49s
Project CI / Backend tests (pull_request) Successful in 6m39s
Project CI / Native shell tests (pull_request) Failing after 5m51s
2026-09-04 15:22:34 +08:00
suzmii f883d4bf2e 修复从项目入口首次进入主画布的错位
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Failing after 10s
移除主画布入场缩放动画避免测量和终态不一致

无效缩略卡测量结果不再直接展示共享场景
2026-09-04 15:13:19 +08:00
suzmii 019b1761f4 修复资源画本首次布局错位
Project CI / Repository checks (pull_request) Failing after 9s
Project CI / Backend tests (pull_request) Failing after 11s
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
首帧测量未完成时隐藏共享场景,避免标题和卡片使用默认坐标

在首帧和资源数量变化后重新校准缩略卡布局
2026-09-04 15:03:28 +08:00
suzmii f339b383f5 实现资源画本统一节点转场
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
重构主画布与子画布为共享资源卡和标题栏节点

补充缩略堆叠、进入返回动画、遮罩与定位适配

同步更新资源画本 UI 技术方案文档
2026-09-04 14:06:24 +08:00
94 changed files with 4451 additions and 5095 deletions
@@ -107,16 +107,12 @@ const rustSharedContractSource = fs.readFileSync(
'utf8',
);
const allowedUncalledTauriCommands = [
'append_direct_project_conversation_message',
'chat_with_game_creator_agent',
'check_ui_editor_font_glyph_coverage',
'create_ui_design_resource',
'open_game_creator_launcher_window',
'open_game_creator_workspace_window',
'read_direct_project_conversation',
'stop_local_game_preview_if_matches',
'start_game_creator_external_mcp',
'stop_game_creator_external_mcp',
];
const sourceExtensions = new Set([
'.json',
@@ -127,39 +127,6 @@ function readBackendTargets({ requireAgcBackend = false } = {}) {
});
}
function readBackendServiceFailure(
state,
{
expectedDatabase = backendDatabase,
expectedSpacetimeDataDir = backendSpacetimeDataDir,
} = {},
) {
const targets = resolveBackendTargetsFromState(state, {
requireAgcBackend: true,
expectedDatabase,
expectedSpacetimeDataDir,
});
if (!targets.hasMatchingBackend) {
return null;
}
for (const serviceName of ['spacetime', 'api-server', 'bgfilter-worker']) {
const service = state?.services?.[serviceName];
if (service?.status !== 'failed') {
continue;
}
return {
serviceName,
failure: service.signal
? `signal=${service.signal}`
: `code=${service.exitCode ?? 1}`,
};
}
return null;
}
async function isBackendReady({
state = readJson(devStackStatePath),
isReady = isHttpReady,
@@ -538,29 +505,11 @@ async function terminateChildTree(
return { stopped, forced: true };
}
async function waitForBackendReady(
backendChild,
timeoutMs = 600_000,
{
checkBackendReady = isBackendReady,
readState = () => readJson(devStackStatePath),
resolveTargets = readBackendTargets,
} = {},
) {
const initialStateUpdatedAt = readState()?.updatedAt ?? '';
async function waitForBackendReady(backendChild, timeoutMs = 600_000) {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
if (await checkBackendReady()) {
return resolveTargets();
}
const state = readState();
if ((state?.updatedAt ?? '') !== initialStateUpdatedAt) {
const serviceFailure = readBackendServiceFailure(state);
if (serviceFailure) {
throw new Error(
`配套后端启动失败: ${serviceFailure.serviceName} ${serviceFailure.failure}`,
);
}
if (await isBackendReady()) {
return readBackendTargets();
}
const failure = readChildFailure(backendChild);
if (failure) {
@@ -737,7 +686,6 @@ export {
isDirectModuleExecution,
isProcessGroupAlive,
preflightExistingVite,
readBackendServiceFailure,
readChildFailure,
readExistingViteServer,
readLinuxProcessGroupAlive,
@@ -14,8 +14,6 @@ mod codex_cli;
mod codex_provider_proxy;
mod direct_codex_attachments;
mod direct_codex_audit;
mod direct_project_history;
mod direct_project_turn_history;
mod direct_runtime;
mod direct_tool_bridge;
mod direct_tools_mcp;
@@ -40,8 +38,6 @@ pub(crate) use codex_cli::{
pub(crate) use codex_provider_proxy::*;
pub(crate) use direct_codex_attachments::*;
pub(crate) use direct_codex_audit::*;
pub(crate) use direct_project_history::*;
pub(crate) use direct_project_turn_history::*;
pub(crate) use direct_runtime::*;
pub(crate) use direct_tool_bridge::*;
pub(crate) use direct_tools_mcp::*;
File diff suppressed because it is too large Load Diff
@@ -1,467 +0,0 @@
use crate::config::prepare_game_creator_private_path_for_read;
use crate::project::{
append_jsonl_line_unlocked, enforce_project_permission_policy, project_append_lock_for,
};
use crate::{LocalConversationMessageRecord, LocalConversationResult};
use serde_json::Value;
use std::fs::File;
use std::io::{BufRead, BufReader, Read, Seek, SeekFrom};
use std::path::{Path, PathBuf};
const DIRECT_PROJECT_HISTORY_RECORD_TYPE: &str = "response_item";
const DIRECT_PROJECT_INTERNAL_CONTEXT_KINDS: &[&str] = &[
"host_skills.instructions",
"permissions.instructions",
"environments.environment_context",
];
const DIRECT_PROJECT_CONTEXTUAL_USER_TEXT_MARKERS: &[(&str, &str)] = &[
("# AGENTS.md instructions", "</INSTRUCTIONS>"),
("<environment_context>", "</environment_context>"),
("<skill>", "</skill>"),
("<user_shell_command>", "</user_shell_command>"),
("<turn_aborted>", "</turn_aborted>"),
("<subagent_notification>", "</subagent_notification>"),
("<recommended_plugins>", "</recommended_plugins>"),
("<goal_context>", "</goal_context>"),
];
fn is_known_contextual_user_text(text: &str) -> bool {
let text = text.trim();
if DIRECT_PROJECT_CONTEXTUAL_USER_TEXT_MARKERS
.iter()
.any(|(start, end)| text.starts_with(start) && text.ends_with(end))
{
return true;
}
if text.starts_with("<codex_internal_context") && text.ends_with("</codex_internal_context>") {
return true;
}
text.starts_with("<external_")
&& text
.split_once('>')
.and_then(|(start, _)| start.strip_prefix("<external_"))
.is_some_and(|key| text.ends_with(&format!("</external_{key}>")))
}
pub(crate) fn is_direct_project_internal_context_item(item: &Value) -> bool {
if matches!(
item.get("role").and_then(Value::as_str),
Some("developer" | "system")
) {
return true;
}
if item
.pointer("/internal_chat_message_metadata_passthrough/content_item_kinds")
.and_then(Value::as_array)
.is_some_and(|kinds| {
kinds
.iter()
.filter_map(Value::as_str)
.any(|kind| DIRECT_PROJECT_INTERNAL_CONTEXT_KINDS.contains(&kind))
})
{
return true;
}
item.get("content")
.and_then(Value::as_array)
.is_some_and(|parts| {
parts.iter().any(|part| {
let Some(text) = part.get("text").and_then(Value::as_str) else {
return false;
};
is_known_contextual_user_text(text)
})
})
}
fn history_path(root: &Path) -> PathBuf {
root.join(".agent/conversations/project.jsonl")
}
fn record(item: &Value) -> Result<String, String> {
serde_json::to_string(&serde_json::json!({
"type": DIRECT_PROJECT_HISTORY_RECORD_TYPE,
"payload": item,
}))
.map_err(|error| format!("序列化 DirectProject 历史失败:{error}"))
}
const DIRECT_PROJECT_HISTORY_REVERSE_SCAN_CHUNK_BYTES: usize = 16 * 1024;
fn find_direct_project_history_item_by_id_at(
path: &Path,
item_id: &str,
) -> Result<Option<Value>, String> {
let mut file = File::open(path)
.map_err(|error| format!("打开 DirectProject 历史失败:{}: {error}", path.display()))?;
let mut position = file
.metadata()
.map_err(|error| {
format!(
"读取 DirectProject 历史元数据失败:{}: {error}",
path.display()
)
})?
.len();
let mut pending = Vec::new();
let mut chunk = vec![0u8; DIRECT_PROJECT_HISTORY_REVERSE_SCAN_CHUNK_BYTES];
loop {
if position == 0 {
break;
}
let read_len = usize::try_from(position)
.unwrap_or(usize::MAX)
.min(chunk.len());
position -= read_len as u64;
file.seek(SeekFrom::Start(position))
.map_err(|error| format!("定位 DirectProject 历史失败:{}: {error}", path.display()))?;
file.read_exact(&mut chunk[..read_len])
.map_err(|error| format!("读取 DirectProject 历史失败:{}: {error}", path.display()))?;
let mut combined = Vec::with_capacity(read_len + pending.len());
combined.extend_from_slice(&chunk[..read_len]);
combined.extend_from_slice(&pending);
let mut line_end = combined.len();
while let Some(newline) = combined[..line_end].iter().rposition(|byte| *byte == b'\n') {
let line = &combined[newline + 1..line_end];
if !line.is_empty() {
if let Some(item) = direct_project_history_item_from_line(path, line)? {
if item.get("id").and_then(Value::as_str) == Some(item_id) {
return Ok(Some(item));
}
}
}
line_end = newline;
}
pending = combined[..line_end].to_vec();
}
if !pending.is_empty() {
// The append path repairs an unterminated final JSONL record before
// writing. A duplicate scan must not reject that repairable tail.
if let Ok(Some(item)) = direct_project_history_item_from_line(path, &pending) {
if item.get("id").and_then(Value::as_str) == Some(item_id) {
return Ok(Some(item));
}
}
}
Ok(None)
}
fn direct_project_history_item_from_line(
path: &Path,
line: &[u8],
) -> Result<Option<Value>, String> {
if line.iter().all(|byte| byte.is_ascii_whitespace()) {
return Ok(None);
}
let parsed: Value = serde_json::from_slice(line)
.map_err(|error| format!("解析 DirectProject 历史失败:{}: {error}", path.display()))?;
if parsed.get("type").and_then(Value::as_str) != Some(DIRECT_PROJECT_HISTORY_RECORD_TYPE) {
return Err(format!(
"DirectProject 历史记录类型无效:{}",
path.display()
));
}
let item = parsed
.get("payload")
.cloned()
.ok_or_else(|| format!("DirectProject 历史记录缺少 payload{}", path.display()))?;
if is_direct_project_internal_context_item(&item) {
return Ok(None);
}
Ok(Some(item))
}
pub(crate) fn append_direct_project_history_item_at(
root: &Path,
item: &Value,
) -> Result<(), String> {
append_direct_project_history_item_at_with_user_policy(root, item, false)
}
/// Appends a user message authored by AGC itself. Codex response items use
/// the default path above, which deliberately ignores echoed user messages;
/// this explicit entry point keeps the two sources distinct.
pub(crate) fn append_direct_project_user_message_at(
root: &Path,
item: &Value,
) -> Result<(), String> {
append_direct_project_history_item_at_with_user_policy(root, item, true)
}
fn append_direct_project_history_item_at_with_user_policy(
root: &Path,
item: &Value,
allow_user_item: bool,
) -> Result<(), String> {
enforce_project_permission_policy(root, "conversation.write")?;
if is_direct_project_internal_context_item(item) {
return Ok(());
}
if !allow_user_item && is_direct_project_codex_user_item(item) {
return Ok(());
}
let _project_lock = crate::project::acquire_project_write_lock(root, "conversation.write")?;
let path = history_path(root);
let history_exists =
prepare_game_creator_private_path_for_read(&path, false, "DirectProject 历史")?;
let lock = project_append_lock_for(&path)?;
let _append_guard = lock.lock("DirectProject 历史追加写")?;
if history_exists {
if let Some(item_id) = item.get("id").and_then(Value::as_str) {
if let Some(existing) = find_direct_project_history_item_by_id_at(&path, item_id)? {
if &existing == item {
return Ok(());
}
return Err(format!("DirectProject 历史 item id 冲突:{item_id}"));
}
}
}
let line = record(item)?;
append_jsonl_line_unlocked(&path, &line, "DirectProject 历史")
}
fn is_direct_project_codex_user_item(item: &Value) -> bool {
if item.get("type").and_then(Value::as_str) == Some("userMessage") {
return true;
}
if item.get("role").and_then(Value::as_str) != Some("user") {
return false;
}
!item
.get("id")
.and_then(Value::as_str)
.is_some_and(|id| id.starts_with("direct-codex:") && id.ends_with(":user"))
}
pub(crate) fn direct_project_local_message_item(
role: &str,
content: &str,
message_id: Option<&str>,
) -> Result<Value, String> {
let role = role.trim();
let content = content.trim();
if content.is_empty() || !matches!(role, "user" | "assistant") {
return Err("DirectProject 只接受非空 user/assistant 历史消息".to_string());
}
let mut item = serde_json::json!({
"type": "message",
"role": role,
"content": [{
"type": if role == "user" { "input_text" } else { "output_text" },
"text": content,
}],
});
if let Some(message_id) = message_id.map(str::trim).filter(|value| !value.is_empty()) {
item["id"] = Value::String(message_id.to_string());
}
Ok(item)
}
pub(crate) fn read_direct_project_history_items_at(root: &Path) -> Result<Vec<Value>, String> {
let path = history_path(root);
if !prepare_game_creator_private_path_for_read(&path, false, "DirectProject 历史")? {
return Ok(Vec::new());
}
let file = File::open(&path)
.map_err(|error| format!("打开 DirectProject 历史失败:{}: {error}", path.display()))?;
let mut items = Vec::new();
let mut reader = BufReader::new(file);
loop {
let mut line = String::new();
let bytes = reader
.read_line(&mut line)
.map_err(|error| format!("读取 DirectProject 历史失败:{}: {error}", path.display()))?;
if bytes == 0 {
break;
}
let had_newline = line.ends_with('\n');
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let parsed: Value = match serde_json::from_str(trimmed) {
Ok(value) => value,
Err(_error) if !had_newline => break,
Err(error) => {
return Err(format!(
"解析 DirectProject 历史失败:{}: {error}",
path.display()
));
}
};
if parsed.get("type").and_then(Value::as_str) != Some(DIRECT_PROJECT_HISTORY_RECORD_TYPE) {
return Err(format!(
"DirectProject 历史记录类型无效:{}",
path.display()
));
}
let item = parsed
.get("payload")
.cloned()
.ok_or_else(|| format!("DirectProject 历史记录缺少 payload{}", path.display()))?;
if is_direct_project_internal_context_item(&item) {
continue;
}
items.push(item);
}
Ok(items)
}
pub(crate) fn read_direct_project_chat_history_at(
root: &Path,
) -> Result<LocalConversationResult, String> {
let path = history_path(root);
let items = read_direct_project_history_items_at(root)?;
let messages = items
.into_iter()
.filter_map(|item| {
let role = item.get("role").and_then(Value::as_str)?;
if !matches!(role, "user" | "assistant") {
return None;
}
let content = item
.get("content")
.and_then(Value::as_array)
.map(|parts| {
parts
.iter()
.filter_map(|part| part.get("text").and_then(Value::as_str))
.collect::<Vec<_>>()
.join("")
})
.unwrap_or_default();
(!content.is_empty()).then(|| LocalConversationMessageRecord {
schema_version: "agc-direct-project-context.v1".to_string(),
role: role.to_string(),
content,
agent_id: None,
message_id: item.get("id").and_then(Value::as_str).map(str::to_string),
updated_at: 0,
})
})
.collect();
Ok(LocalConversationResult {
path: path.to_string_lossy().into_owned(),
agent_id: None,
session_id: None,
messages,
})
}
#[cfg(test)]
mod tests {
use super::{
append_direct_project_history_item_at, append_direct_project_user_message_at, history_path,
is_direct_project_internal_context_item, read_direct_project_history_items_at,
};
use serde_json::json;
#[test]
fn filters_host_context_but_keeps_real_user_items() {
assert!(is_direct_project_internal_context_item(&json!({
"type": "message",
"role": "developer",
"content": [{"type": "input_text", "text": "<skills_instructions>"}]
})));
assert!(is_direct_project_internal_context_item(&json!({
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "<environment_context>"}],
"internal_chat_message_metadata_passthrough": {
"content_item_kinds": ["environments.environment_context"]
}
})));
assert!(!is_direct_project_internal_context_item(&json!({
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "ls ui"}],
"internal_chat_message_metadata_passthrough": {
"content_item_kinds": ["user.text"]
}
})));
}
#[test]
fn filters_codex_contextual_user_item_without_passthrough_metadata() {
assert!(is_direct_project_internal_context_item(&json!({
"type": "message",
"role": "user",
"content": [{
"type": "input_text",
"text": "<environment_context>\n<cwd>/tmp/project</cwd>\n</environment_context>"
}]
})));
assert!(!is_direct_project_internal_context_item(&json!({
"type": "message",
"role": "user",
"content": [{
"type": "input_text",
"text": "请读取 <environment_context> 中的说明"
}]
})));
}
#[test]
fn append_repairs_truncated_tail_before_idempotency_scan() {
let root = tempfile::tempdir().expect("temp project");
crate::init_local_game_project_at(root.path(), "tail-repair", "尾行修复")
.expect("init project");
let path = history_path(root.path());
std::fs::create_dir_all(path.parent().expect("history parent")).expect("history dir");
std::fs::write(
&path,
br#"{"type":"response_item","payload":{"type":"message"}"#,
)
.expect("write truncated history tail");
let item = serde_json::json!({
"type": "message",
"role": "assistant",
"id": "tail-repair-item",
"content": [{"type": "output_text", "text": "已修复"}]
});
append_direct_project_history_item_at(root.path(), &item).expect("repair and append");
let items =
read_direct_project_history_items_at(root.path()).expect("read repaired history");
assert_eq!(items, vec![item]);
}
#[test]
fn codex_user_echo_is_filtered_but_agc_user_message_is_persisted() {
let root = tempfile::tempdir().expect("temp project");
crate::init_local_game_project_at(root.path(), "user-echo", "用户回显过滤")
.expect("init project");
let user = serde_json::json!({
"type": "message",
"role": "user",
"id": "direct-codex:turn-0001:user",
"content": [{"type": "input_text", "text": "请创建菜单"}]
});
append_direct_project_user_message_at(root.path(), &user).expect("persist AGC user");
append_direct_project_history_item_at(
root.path(),
&serde_json::json!({
"type": "userMessage",
"id": "codex-user-item-1",
"clientId": "turn-0001",
"content": [{"type": "text", "text": "请创建菜单"}]
}),
)
.expect("ignore Codex echo");
append_direct_project_history_item_at(
root.path(),
&serde_json::json!({
"type": "message",
"role": "user",
"id": "codex-raw-user-item-1",
"content": [{"type": "input_text", "text": "请创建菜单"}]
}),
)
.expect("ignore raw Codex user echo");
let items = read_direct_project_history_items_at(root.path()).expect("read history");
assert_eq!(items, vec![user]);
}
}
@@ -1,54 +0,0 @@
use super::direct_project_history::append_direct_project_history_item_at;
use serde_json::Value;
use std::collections::BTreeMap;
use std::path::Path;
#[derive(Default)]
pub(crate) struct DirectProjectHistoryAccumulator {
text_by_item_id: BTreeMap<String, String>,
}
impl DirectProjectHistoryAccumulator {
pub(crate) fn observe_delta(&mut self, item_id: &str, delta: &str) {
self.text_by_item_id
.entry(item_id.to_string())
.or_default()
.push_str(delta);
}
pub(crate) fn complete_item(&mut self, item: &Value) {
if let Some(item_id) = item.get("id").and_then(Value::as_str) {
self.text_by_item_id.remove(item_id);
}
}
fn take_partial_items(&mut self) -> impl Iterator<Item = Value> + '_ {
std::mem::take(&mut self.text_by_item_id)
.into_iter()
.filter(|(_, text)| !text.is_empty())
.map(|(item_id, text)| {
serde_json::json!({
"type": "message",
"role": "assistant",
"id": item_id,
"content": [{"type": "output_text", "text": text}],
})
})
}
}
pub(crate) fn persist_direct_project_partial_items_at(
root: &Path,
accumulator: &mut DirectProjectHistoryAccumulator,
) -> Result<(), String> {
let items = accumulator.take_partial_items().collect::<Vec<_>>();
let mut first_error = None;
for item in items {
if let Err(error) = append_direct_project_history_item_at(root, &item) {
if first_error.is_none() {
first_error = Some(error);
}
}
}
first_error.map_or(Ok(()), Err)
}
@@ -2147,7 +2147,7 @@ fn direct_registered_taonier_slice_paths(root: &Path) -> Vec<String> {
let Ok(validated_slices) = validated_art_slices(root) else {
return Vec::new();
};
if validated_slices.is_empty() {
if validated_slices.len() != 4 {
return Vec::new();
}
let mut resource_ids = std::collections::HashSet::with_capacity(validated_slices.len());
@@ -2718,7 +2718,6 @@ async fn generate_direct_taonier_art_asset_at(
asset_kind: asset_kind.to_string(),
asset_label: asset_label.to_string(),
replace_existing: root.join(output_path).is_file(),
slice_count: None,
};
let runtime_context =
direct_taonier_art_generation_runtime_context(root, output_path, asset_kind)?;
@@ -2822,9 +2821,9 @@ fn direct_taonier_art_package_result(
} else {
Vec::new()
};
if includes_spritesheet && slice_paths.is_empty() {
if includes_spritesheet && slice_paths.len() != 4 {
slice_warnings.push(
"当前图集没有可验证的独立切片;只能使用完整图集,不得猜测切片或伪造衍生素材"
"当前核心图集没有可验证的独立切片;只能使用完整图集,不得猜测切片或伪造衍生素材"
.to_string(),
);
}
@@ -4407,6 +4406,48 @@ fn normalize_direct_client_turn_id(client_turn_id: Option<&str>) -> Result<Strin
Ok(client_turn_id.to_string())
}
fn persist_direct_codex_assistant_reply_at(
root: &Path,
client_turn_id: &str,
reply: &str,
) -> Result<(), String> {
enforce_project_permission_policy(root, "conversation.write")?;
let _lock = acquire_project_write_lock(root, "conversation.write")?;
append_local_conversation_message_for_session_idempotent_at(
root,
None,
None,
LocalConversationMessage {
role: "assistant".to_string(),
content: reply.to_string(),
agent_id: None,
},
&format!("direct-codex:{client_turn_id}:assistant"),
)
.map(|_| ())
}
fn persist_direct_codex_user_prompt_at(
root: &Path,
client_turn_id: &str,
prompt: &str,
) -> Result<(), String> {
enforce_project_permission_policy(root, "conversation.write")?;
let _lock = acquire_project_write_lock(root, "conversation.write")?;
append_local_conversation_message_for_session_idempotent_at(
root,
None,
None,
LocalConversationMessage {
role: "user".to_string(),
content: prompt.trim().to_string(),
agent_id: None,
},
&format!("direct-codex:{client_turn_id}:user"),
)
.map(|_| ())
}
#[tauri::command]
pub(crate) async fn chat_with_game_creator_direct_codex(
project_path: String,
@@ -4438,6 +4479,15 @@ pub(crate) async fn chat_with_game_creator_direct_codex(
return Err(error);
}
};
if let Err(error) = persist_direct_codex_user_prompt_at(root, &turn_id, &user_prompt) {
audit.finish(false);
turn_emitter.emit("failed", Some("none"), None);
return Err(redact_agent_runtime_error(
root,
&format!("Direct 用户消息持久化失败,已拒绝发起回合:{error}"),
500,
));
}
let reply = match run_direct_game_creator_turn_at_with_creation_type_and_emitter(
root,
&user_prompt,
@@ -4453,6 +4503,15 @@ pub(crate) async fn chat_with_game_creator_direct_codex(
return Err(error);
}
};
if let Err(error) = persist_direct_codex_assistant_reply_at(root, &turn_id, &reply) {
audit.finish(false);
turn_emitter.emit("failed", Some("none"), None);
return Err(redact_agent_runtime_error(
root,
&format!("Direct 成功回复持久化失败,已拒绝以未落盘状态返回:{error}"),
500,
));
}
audit.finish(true);
turn_emitter.emit("completed", Some("none"), Some(reply.clone()));
Ok(reply)
@@ -4466,34 +4525,6 @@ pub(crate) async fn chat_with_game_creator_home_direct_codex(
run_direct_game_creator_home_turn(&prompt, attachments.as_deref().unwrap_or_default()).await
}
#[cfg(test)]
fn persist_direct_codex_user_prompt_at(
root: &Path,
client_turn_id: &str,
prompt: &str,
) -> Result<(), String> {
let item = direct_project_local_message_item(
"user",
prompt,
Some(&format!("direct-codex:{client_turn_id}:user")),
)?;
append_direct_project_user_message_at(root, &item)
}
#[cfg(test)]
fn persist_direct_codex_assistant_reply_at(
root: &Path,
client_turn_id: &str,
reply: &str,
) -> Result<(), String> {
let item = direct_project_local_message_item(
"assistant",
reply,
Some(&format!("direct-codex:{client_turn_id}:assistant")),
)?;
append_direct_project_history_item_at(root, &item)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -4578,8 +4609,8 @@ mod tests {
persist_direct_codex_assistant_reply_at(root.path(), turn_id, reply)
.expect("the App's same-id write converges idempotently");
let conversation =
read_direct_project_chat_history_at(root.path()).expect("read project conversation");
let conversation = read_local_conversation_for_session_at(root.path(), None, None)
.expect("read project conversation");
let message_id = format!("direct-codex:{turn_id}:assistant");
let persisted = conversation
.messages
@@ -4592,7 +4623,7 @@ mod tests {
assert!(
persist_direct_codex_assistant_reply_at(root.path(), turn_id, "不同回复")
.expect_err("same message identity cannot be rebound")
.contains("item id 冲突")
.contains("messageId 冲突")
);
}
@@ -4609,8 +4640,8 @@ mod tests {
persist_direct_codex_user_prompt_at(root.path(), turn_id, normalized_prompt)
.expect("retry reuses the same user message identity");
let conversation =
read_direct_project_chat_history_at(root.path()).expect("read project conversation");
let conversation = read_local_conversation_for_session_at(root.path(), None, None)
.expect("read project conversation");
let message_id = format!("direct-codex:{turn_id}:user");
let persisted = conversation
.messages
@@ -4623,7 +4654,7 @@ mod tests {
assert!(
persist_direct_codex_user_prompt_at(root.path(), turn_id, "不同的重试请求")
.expect_err("same turn identity cannot be rebound")
.contains("item id 冲突")
.contains("messageId 冲突")
);
}
@@ -63,6 +63,7 @@ struct DirectToolBridgeTurnAuthorization {
struct DirectToolBridgeActiveTurnAuthorization {
turn_id: String,
allows_regeneration: bool,
brief_sha256: Option<String>,
completed_result: Option<Value>,
resource_request_ids: BTreeMap<String, (String, String)>,
@@ -180,23 +181,30 @@ impl DirectToolBridge {
&self.url
}
/// Arm exactly one client-owned Direct turn. Codex chooses the business
/// operation through the reviewed MCP tool and arguments; the bridge only
/// binds that call to the active client turn.
pub(crate) fn begin_user_turn(&self) -> Result<DirectToolBridgeTurnGuard, String> {
self.state.begin_user_turn()
/// Arm exactly one client-owned Direct turn. The raw user message is used
/// only for this synchronous decision and is never retained by the bridge.
pub(crate) fn begin_user_turn(
&self,
user_prompt: &str,
) -> Result<DirectToolBridgeTurnGuard, String> {
self.state.begin_user_turn(user_prompt)
}
}
impl DirectToolBridgeState {
fn begin_user_turn(self: &Arc<Self>) -> Result<DirectToolBridgeTurnGuard, String> {
fn begin_user_turn(
self: &Arc<Self>,
user_prompt: &str,
) -> Result<DirectToolBridgeTurnGuard, String> {
let turn_id = direct_taonier_active_invocation_id_at(&self.root)?;
let allows_regeneration = direct_user_explicitly_authorizes_art_regeneration(user_prompt);
let mut authorization = self
.turn_authorization
.lock()
.map_err(|_| "AGC 工具桥回合授权状态不可用".to_string())?;
authorization.active = Some(DirectToolBridgeActiveTurnAuthorization {
turn_id: turn_id.clone(),
allows_regeneration,
brief_sha256: None,
completed_result: None,
resource_request_ids: BTreeMap::new(),
@@ -586,9 +594,12 @@ impl DirectToolBridgeState {
.active
.as_mut()
.ok_or_else(|| "当前没有客户端签发的美术重生成回合授权".to_string())?;
if !active.allows_regeneration {
return Err("当前用户消息未显式授权重新生成或替换美术".to_string());
}
match active.brief_sha256.as_deref() {
Some(expected) if expected != brief_sha256 => {
return Err("当前客户端回合已绑定另一项稳定美术重生成请求".to_string())
return Err("当前用户授权已绑定另一项稳定美术重生成请求".to_string())
}
None => active.brief_sha256 = Some(brief_sha256.clone()),
Some(_) => {}
@@ -2105,7 +2116,6 @@ async fn bridge_generate_image(state: &DirectToolBridgeState, arguments: &Value)
asset_kind: kind.clone(),
asset_label: asset_name.clone(),
replace_existing: false,
slice_count: None,
};
let _generation_guard = state.image_generation_gate.lock().await;
let generated = with_direct_editor_api_credentials(
@@ -2726,20 +2736,106 @@ mod tests {
}
#[test]
fn regenerate_uses_current_client_turn_and_one_stable_brief() {
fn regenerate_requires_current_explicit_user_authorization_and_one_stable_brief() {
for prompt in [
"继续修复布局",
"解释一下重新生成美术是什么意思",
"不要重新生成美术,只调整代码",
"别换一套美术,继续用现在这套",
"解释一下换一套美术按钮",
"是否要改变视觉风格?",
"Do not regenerate the art; keep the current package.",
"I don't want to change the visual style.",
"What does use a new art set mean?",
"文案写着“换一套美术”",
"Yesterday I said regenerate art, but today keep it.",
"Please explain how to regenerate art.",
"重新生成美术以后再说,现在只修代码",
"重做美术先不做,先改玩法",
"Regenerate the art maybe later; for now just fix the code.",
"把按钮文案改成“请重新生成美术”,不要执行生成工具",
"把按钮文案改成‘请重新生成美术’,不要执行生成工具",
"Change the button label to 'please regenerate the art'; do not execute it.",
"用户之前说请重新生成美术,我只是在复述",
"Yesterday the user said please regenerate the art; I am just quoting it.",
"以后请重新生成美术,现在先改代码",
"你能不能帮我重新生成美术,顺便解释一下价格",
"请重新生成美术吗",
"请重新生成美术吗,还是只改代码",
"请重新生成美术或者只改代码",
"请重新生成美术以外的内容",
"请重新生成美术,但不要执行生成工具",
"不需要重新生成美术",
"界面上显示:请重新生成美术",
"界面标题是请重新生成美术",
"产品经理让我写请重新生成美术",
"下周请重新生成美术",
"他说«请重新生成美术»",
"Could you please regenerate the art",
"Please regenerate the art? Or only fix code.",
"Please regenerate the art except for the paid generation.",
"Please regenerate the art, but do not execute the tool.",
"Please regenerate the art, but dont execute the tool.",
"Please regenerate the art, but I don't authorize this paid generation.",
"Please regenerate the art, but I dont authorize this paid generation.",
"Please regenerate the art, but do not execute the paid tool.",
"Please regenerate the art, but never execute the paid tool.",
"Please regenerate the art, but avoid executing the paid tool.",
"Please regenerate the art, but 'do not execute the tool",
"Please regenerate the art only if it is free.",
"Please regenerate the art only after I confirm the charge.",
"Please regenerate the art, but do “not” execute the paid tool.",
"请重新生成美术,三天后再执行。",
"请重新生成美术,得到我的许可再做。",
"请重新生成美术,地面需要无缝循环。",
"Please regenerate the art, but skip the paid generation.",
"请重新生成美术【生成操作跳过】",
"请重新生成美术【仅在零元时执行】",
"Please regenerate the art “but skip the paid generation”",
"Please regenerate the art; alternatively, just fix the code.",
"Please regenerate the art, but do n\u{200B}ot execute the paid tool.",
"Please regenerate the art with a clay style.",
"I don't need you to regenerate the art",
"The UI shows: please regenerate the art",
"Please regenerate the art next week",
"He said «please regenerate the art»",
] {
assert!(
!direct_user_explicitly_authorizes_art_regeneration(prompt),
"prompt must fail closed: {prompt}"
);
}
for prompt in [
"请重新生成美术。",
"那就请重新生成美术!",
"换一套美术",
"Please regenerate the art!",
] {
assert!(
direct_user_explicitly_authorizes_art_regeneration(prompt),
"prompt must explicitly authorize: {prompt}"
);
}
let root = tempfile::tempdir().expect("stable client turn root");
let state = direct_tool_bridge_state(root.path().to_path_buf());
assert!(state.begin_user_turn().is_err());
assert!(state.begin_user_turn("请重新生成美术").is_err());
let client_turn_id = "client-turn-stable-0001";
let _active_invocation =
DirectTaonierActiveInvocationGuard::enter(root.path(), client_turn_id)
.expect("client-owned stable invocation");
let active_turn = state
.begin_user_turn()
.expect("client turn authorization state");
let ordinary_turn = state
.begin_user_turn("继续优化交互")
.expect("ordinary turn authorization state");
assert!(state.authorize_regeneration_call("陶泥风格").is_err());
drop(ordinary_turn);
let authorized_turn = state
.begin_user_turn("请重新生成美术")
.expect("authorized regeneration turn");
let (turn_id, brief_sha256) = match state
.authorize_regeneration_call("陶泥风格")
.expect("MCP mode selects regeneration explicitly")
.expect("first stable regeneration call")
{
DirectToolBridgeRegenerationCall::Execute {
turn_id,
@@ -2768,7 +2864,7 @@ mod tests {
panic!("completed stable retry must not execute a second paid call")
}
}
drop(active_turn);
drop(authorized_turn);
assert!(state.authorize_regeneration_call("陶泥风格").is_err());
}
File diff suppressed because it is too large Load Diff
@@ -412,7 +412,6 @@ pub(crate) struct PlatformArtAssetGenerationOptions {
pub(crate) asset_kind: String,
pub(crate) asset_label: String,
pub(crate) replace_existing: bool,
pub(crate) slice_count: Option<usize>,
}
impl Default for PlatformArtAssetGenerationOptions {
@@ -424,7 +423,6 @@ impl Default for PlatformArtAssetGenerationOptions {
asset_kind: "game-art".to_string(),
asset_label: "AI 游戏首版美术素材".to_string(),
replace_existing: false,
slice_count: None,
}
}
}
@@ -683,47 +681,6 @@ pub(in crate::agent) fn platform_art_generation_error_result_unknown(error: &str
error.starts_with(EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX)
}
/// Observe an accepted operation once without waiting. A single GET that
/// reports `failed` is authoritative and allows a changed retry to release
/// the old local slot; queued/running/unknown outcomes remain protected.
async fn accepted_generation_is_authoritatively_failed_once(
client: &reqwest::Client,
access: &ExternalEditorBindingAccess<'_>,
submission_payload: &serde_json::Value,
) -> Result<bool, String> {
let submission = external_editor_response_data(submission_payload);
let operation_id = json_string_field(submission, "operationId")
.ok_or_else(|| "External Editor accepted 账本缺少 operationId".to_string())?;
access.validate_frozen_session()?;
let payload = tokio::time::timeout(
Duration::from_secs(3),
external_editor_json_request(
client
.get(format!(
"{}{}",
access.api_base_url(),
access.generation_status_route(&operation_id)
))
.bearer_auth(access.bearer_token()),
"查询平台图片生成任务",
),
)
.await
.map_err(|_| "查询平台图片生成任务超时".to_string())??;
access.validate_frozen_session()?;
let generation = platform_generation_status_data(&payload);
match json_string_field(generation, "status").as_deref() {
Some("failed") => Ok(true),
Some("queued" | "running" | "completed") => Ok(false),
Some(status) => Err(format!(
"{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 平台图片生成任务返回未知状态 {status}operationId={operation_id}"
)),
None => Err(format!(
"{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 平台图片生成任务状态响应缺少 statusoperationId={operation_id}"
)),
}
}
pub(crate) async fn external_editor_json_request(
request: reqwest::RequestBuilder,
action: &str,
@@ -1757,16 +1714,24 @@ fn canonical_art_spritesheet_icon_descriptions(prompt: &str) -> Vec<String> {
// long creation request cannot reject the atlas before it is queued.
const MAX_DESCRIPTION_CHARS: usize = 200;
const CONTEXT_PREFIX: &str = ";遵循同一项目视觉规范:";
let category =
"按当前项目需求生成一组可独立使用的透明素材;数量、类别、排列和切片方式由本次需求决定";
let context_budget = MAX_DESCRIPTION_CHARS.saturating_sub(
category
.chars()
.count()
.saturating_add(CONTEXT_PREFIX.chars().count()),
);
let project_context = truncate_inline_bounded(prompt.trim(), context_budget);
vec![format!("{category}{CONTEXT_PREFIX}{project_context}")]
[
"第 1 类(左上):当前玩法的玩家主体或主要操作对象;只生成一个轮廓连贯、可独立使用的完整素材",
"第 2 类(右上):当前玩法的方块、目标物、收集物、敌对实体或危险物;只生成一个完整素材",
"第 3 类(左下):当前玩法需要的地块、障碍、资源物件或场景装饰;只生成一个完整素材",
"第 4 类(右下):得分、受击、成长、失败、胜利或操作反馈特效;只生成一个完整素材",
]
.into_iter()
.map(|category| {
let context_budget = MAX_DESCRIPTION_CHARS.saturating_sub(
category
.chars()
.count()
.saturating_add(CONTEXT_PREFIX.chars().count()),
);
let project_context = truncate_inline_bounded(prompt.trim(), context_budget);
format!("{category}{CONTEXT_PREFIX}{project_context}")
})
.collect()
}
fn truncate_inline_bounded(value: &str, max_chars: usize) -> String {
@@ -2078,13 +2043,13 @@ pub(crate) async fn generate_platform_art_asset_with_required_slices_at(
}
let generation_prompt = build_platform_art_asset_prompt(prompt, briefs, options);
let runtime_context =
standalone_platform_art_generation_runtime_context(&generation_prompt, options, false)?;
standalone_platform_art_generation_runtime_context(&generation_prompt, options, true)?;
generate_platform_art_asset_with_runtime_options_at(
root,
prompt,
briefs,
options,
false,
true,
&runtime_context,
)
.await
@@ -2462,34 +2427,6 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at
)
})?;
if snapshot.generation_prompt != generation_prompt {
if platform_art_generation_runtime_status(&state) == "accepted" {
if let Ok(submission) = platform_art_generation_runtime_submission_payload(&state) {
if accepted_generation_is_authoritatively_failed_once(
&client,
&binding_access,
&submission,
)
.await
.unwrap_or(false)
{
if let Some(context) = runtime_context {
remove_platform_art_generation_runtime_state_at(
root,
&context.agent_id,
&context.run_id,
)?;
}
return Box::pin(request_platform_art_asset_with_runtime_options_at(
root,
prompt,
briefs,
options,
runtime_context,
))
.await;
}
}
}
return Err(format!(
"{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 当前生成意图与已持久化请求快照不一致,已拒绝将旧操作当作本次请求恢复;原生成账本已保留,需要先完成或对账旧操作"
));
@@ -2511,36 +2448,6 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at
)
})?;
if snapshot.reference_resource_ids != [current_reference] {
if platform_art_generation_runtime_status(&state) == "accepted" {
if let Ok(submission) =
platform_art_generation_runtime_submission_payload(&state)
{
if accepted_generation_is_authoritatively_failed_once(
&client,
&binding_access,
&submission,
)
.await
.unwrap_or(false)
{
if let Some(context) = runtime_context {
remove_platform_art_generation_runtime_state_at(
root,
&context.agent_id,
&context.run_id,
)?;
}
return Box::pin(request_platform_art_asset_with_runtime_options_at(
root,
prompt,
briefs,
options,
runtime_context,
))
.await;
}
}
}
return Err(format!(
"{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 当前规范图身份与已持久化派生请求不一致,已拒绝恢复旧操作;原生成账本已保留,需要先完成或对账旧操作"
));
@@ -2649,7 +2556,7 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at
serde_json::json!({
"referenceId": reference_id,
"iconDescriptions": canonical_art_spritesheet_icon_descriptions(&generation_prompt),
"sliceCount": options.slice_count,
"sliceLayout": "grid-2x2",
"screenColor": "auto",
"aspectRatio": options.aspect_ratio,
"imageSize": options.image_size,
@@ -6352,8 +6259,11 @@ fn validate_strict_platform_art_spritesheet_contract(
has_transparent_pixels: bool,
has_visible_pixels: bool,
) -> Result<(), String> {
if slices.is_empty() {
return Err("spritesheet 图集至少需要一个独立切片".to_string());
if slices.len() != 4 {
return Err(format!(
"strict spritesheet 图集必须恰好包含 4 个独立切片,实际为 {} 个",
slices.len()
));
}
let resource_id = resource_id
.map(str::trim)
@@ -6384,7 +6294,12 @@ fn validate_strict_platform_art_spritesheet_contract(
{
return Err("strict spritesheet 图集生成 route/kind 与严格图集合同不一致".to_string());
}
let _requested_slice_layout = spritesheet_slice_layout;
if spritesheet_slice_layout.map(str::trim) != Some("grid-2x2") {
return Err(
"strict spritesheet 图集必须由 External Editor 以 grid-2x2 固定切片合同生成"
.to_string(),
);
}
if reference_resource_ids.len() != 1
|| reference_resource_ids[0].trim().is_empty()
|| reference_resource_ids[0].trim() == resource_id
@@ -6719,7 +6634,7 @@ fn existing_platform_art_slice_registrations_are_complete(
manifest: &GameCreationAppManifest,
registrations: &[PlatformArtSliceManifestRegistration],
) -> Result<bool, String> {
if registrations.is_empty() {
if registrations.len() != 4 {
return Ok(false);
}
let mut resource_ids = std::collections::HashSet::with_capacity(registrations.len());
@@ -7679,7 +7594,6 @@ mod canvas_generation_tests {
asset_kind: "game-background".to_string(),
asset_label: "手工背景".to_string(),
replace_existing: true,
slice_count: None,
};
let ordinary =
standalone_platform_art_generation_runtime_context("完整生成提示词", &options, false)
@@ -9488,7 +9402,6 @@ mod canvas_generation_tests {
asset_kind: "icon-spec".to_string(),
asset_label: "整包规范图".to_string(),
replace_existing: false,
slice_count: None,
};
let prompt = "生成同一套整包美术";
let generation_prompt = build_platform_art_asset_prompt(prompt, &[], &options);
@@ -10326,7 +10239,6 @@ mod canvas_generation_tests {
asset_kind: "game-background".to_string(),
asset_label: "整包背景图".to_string(),
replace_existing: false,
slice_count: None,
};
let prompt = "保持同一个生成提示词";
let generation_prompt = build_platform_art_asset_prompt(prompt, &[], &options);
@@ -10778,7 +10690,6 @@ mod canvas_generation_tests {
asset_kind: "icon-spec".to_string(),
asset_label: "游戏统一视觉规范图".to_string(),
replace_existing: false,
slice_count: None,
};
let prompt = "恢复已受理视觉规范图";
let generation_prompt = build_platform_art_asset_prompt(prompt, &[], &options);
@@ -11353,7 +11264,6 @@ mod canvas_generation_tests {
asset_kind: "art-spritesheet".to_string(),
asset_label: "游戏首版核心美术素材".to_string(),
replace_existing: true,
slice_count: None,
}
}
File diff suppressed because one or more lines are too long
@@ -453,9 +453,16 @@ pub(in crate::agent) fn validate_agent_runtime_autonomous_initial_collaboration_
"首批 art-director 必须是非只读规范图生成任务",
));
}
// 图片产物由 Codex 按项目需求决定;不再要求固定 art-spec.png。
let _art_artifacts =
let art_artifacts =
autonomous_initial_delegate_expected_artifacts(art_director, "art-director")?;
if !art_artifacts
.iter()
.any(|path| path == "assets/art-spec.png")
{
return Err(autonomous_initial_collaboration_contract_error(
"首批 art-director 的 expectedArtifacts 必须包含 assets/art-spec.png",
));
}
let code_director = code_director.ok_or_else(|| {
autonomous_initial_collaboration_contract_error("首批缺少 code-director 委派")
@@ -1969,7 +1976,7 @@ mod tests {
plan: Vec::new(),
actions: vec![
autonomous_initial_delegate("design-director", &[]),
autonomous_initial_delegate("art-director", &[]),
autonomous_initial_delegate("art-director", &["assets/art-spec.png"]),
autonomous_initial_delegate("code-director", &[]),
],
response: String::new(),
@@ -1199,36 +1199,32 @@ pub(in crate::agent) fn visual_asset_completion_blocker_at_locked(
agent_id: &str,
required_run_id: Option<&str>,
) -> Option<AgentRuntimeToolObservation> {
// 图片产物由 Codex 按项目需求选择,不再存在固定视觉资产完成门禁。
return None;
#[allow(unreachable_code)]
{
if !editor_api_key_is_configured() {
return None;
}
let (expected_path, expected_kind, label) = match agent_id {
"art-director" => (AGENT_RUNTIME_ART_SPEC_PATH, "icon-spec", "统一视觉规范图"),
"design-foundation" => ("assets/ui-prototype.png", "ui-prototype", "策划界面原型"),
"art-asset-plan" => (
"assets/art-spritesheet.png",
"art-spritesheet",
"首版美术素材图",
),
_ => return None,
};
let manifest = match read_manifest_for_project(root) {
Ok(manifest) => manifest,
Err(error) => {
return Some(AgentRuntimeToolObservation {
tool: "runtime.visual_asset".to_string(),
status: "blocked".to_string(),
summary: format!("无法核对{label},不能完成任务"),
detail: Some(redact_agent_runtime_project_paths(root, &error, 500)),
});
}
};
if let Err(error) = validate_manifest_required_visual_asset(root, &manifest, agent_id) {
if !editor_api_key_is_configured() {
return None;
}
let (expected_path, expected_kind, label) = match agent_id {
"art-director" => (AGENT_RUNTIME_ART_SPEC_PATH, "icon-spec", "统一视觉规范图"),
"design-foundation" => ("assets/ui-prototype.png", "ui-prototype", "策划界面原型图"),
"art-asset-plan" => (
"assets/art-spritesheet.png",
"art-spritesheet",
"首版美术素材",
),
_ => return None,
};
let manifest = match read_manifest_for_project(root) {
Ok(manifest) => manifest,
Err(error) => {
return Some(AgentRuntimeToolObservation {
tool: "runtime.visual_asset".to_string(),
status: "blocked".to_string(),
summary: format!("无法核对{label},不能完成任务"),
detail: Some(redact_agent_runtime_project_paths(root, &error, 500)),
});
}
};
if let Err(error) = validate_manifest_required_visual_asset(root, &manifest, agent_id) {
return Some(AgentRuntimeToolObservation {
tool: "runtime.visual_asset".to_string(),
status: "blocked".to_string(),
summary: format!("{label}尚未按正式视觉流程生成并登记,不能完成任务"),
@@ -1238,30 +1234,29 @@ pub(in crate::agent) fn visual_asset_completion_blocker_at_locked(
redact_agent_runtime_project_paths(root, &error, 300),
)),
});
}
if agent_id != "design-foundation" {
return None;
}
match ui_prototype_visual_inspection_blocker_detail_at_locked(
root,
agent_id,
required_run_id,
expected_path,
) {
Ok(None) => None,
Ok(Some(detail)) => Some(AgentRuntimeToolObservation {
tool: "runtime.visual_asset".to_string(),
status: "blocked".to_string(),
summary: "策划界面原型图尚未通过结构化 UI 视觉检查,不能完成任务".to_string(),
detail: Some(detail),
}),
Err(error) => Some(AgentRuntimeToolObservation {
tool: "runtime.visual_asset".to_string(),
status: "blocked".to_string(),
summary: "无法核对策划界面原型图的结构化 UI 视觉证据,不能完成任务".to_string(),
detail: Some(redact_agent_runtime_project_paths(root, &error, 500)),
}),
}
}
if agent_id != "design-foundation" {
return None;
}
match ui_prototype_visual_inspection_blocker_detail_at_locked(
root,
agent_id,
required_run_id,
expected_path,
) {
Ok(None) => None,
Ok(Some(detail)) => Some(AgentRuntimeToolObservation {
tool: "runtime.visual_asset".to_string(),
status: "blocked".to_string(),
summary: "策划界面原型图尚未通过结构化 UI 视觉检查,不能完成任务".to_string(),
detail: Some(detail),
}),
Err(error) => Some(AgentRuntimeToolObservation {
tool: "runtime.visual_asset".to_string(),
status: "blocked".to_string(),
summary: "无法核对策划界面原型图的结构化 UI 视觉证据,不能完成任务".to_string(),
detail: Some(redact_agent_runtime_project_paths(root, &error, 500)),
}),
}
}
@@ -1756,7 +1756,11 @@ pub(in crate::agent) fn project_autonomous_manifest_ready_task_terminal_at_locke
}
pub(super) fn autonomous_manifest_ready_task_requires_visual_asset(task_id: &str) -> bool {
false
editor_api_key_is_configured()
&& matches!(
task_id,
"art-director" | "design-foundation" | "art-asset-plan"
)
}
fn render_autonomous_manifest_ready_task_owner_prompt(task: &GameCreationAppTaskState) -> String {
@@ -1773,7 +1777,11 @@ fn render_autonomous_manifest_ready_task_owner_prompt(task: &GameCreationAppTask
} else {
""
};
let visual_requirement = "任务声明中的视觉图片按项目需求选择工具、数量、输出路径、尺寸和布局;需要图集时用 sliceCount 指定切片数量。Runtime 只核对实际声明的资源登记,不要求固定图片合同。";
let visual_requirement = if task.id == "art-asset-plan" && editor_api_key_is_configured() {
"art-asset-plan 的固定成功路径是:调用 canvas.asset_generate 生成并登记 assets/art-spritesheet.pngassetKind=art-spritesheet),然后调用 asset.list 核对图集及四个 canonical 切片已经登记,再调用 file.write 写入 assets/manifest.art.json;完成这组动作后把结构化计划最后一步标记 completed 并立即交付。不要调用 image.inspect,不要根据图片主观观感发起返工或 agent.message;图集视觉质量由后续质量任务处理,Runtime 会在收束门内验证文件和资产登记状态。"
} else {
"任务声明中的视觉图片继续按现有 visual gate 生成、登记并验收。"
};
let verification_requirement = match task.id.as_str() {
"code-prototype" => "code-prototype 必须对可玩入口执行 game.static_smoke;完整 DAG 的最终静态与浏览器验收继续由后续质量任务承担。",
task_id if agent_runtime_autonomous_uses_owner_artifact_validation(task_id) => "完成固定正式产物后直接交付,由 Runtime 在收束门内验证本人固定 owner 产物;禁止调用 game.static_smoke、project.verify、command.run_limited 或 preview 工具冒充 owner 产物验证。",
@@ -1802,7 +1810,7 @@ pub(in crate::agent) fn render_autonomous_manifest_ready_task_background_prompt(
if task.id == "art-director" {
if autonomous_manifest_ready_task_requires_visual_asset(&task.id) {
return format!(
"{base}\n\n这是 autonomous-game-build 的视觉方向任务。根据项目需求决定是否调用 canvas.asset_generate,不规定固定图片名称、数量、素材类别或布局;生成成功后直接交付结论"
"{base}\n\n这是 autonomous-game-build 的非只读视觉规范生成任务。{AGENT_RUNTIME_AUTONOMOUS_ART_DIRECTOR_CANVAS_ONLY_TASK_MARKER};必须用固定合同生成并登记 assets/art-spec.pngassetKind=icon-spec、aspectRatio=1:1),该受控素材事务会同时提交当前 run 的 mutation 与验证凭证。禁止调用 file.write、file.patch、file.delete、project.patchset、project.restore 或写入其它路径。生成成功后直接交付视觉规范结论;不要调用 task.updateRuntime 会在子 Run 终态后幂等投影 manifest"
);
}
return format!(
@@ -534,7 +534,15 @@ pub(crate) fn observe_agent_runtime_agent_delegate_at_locked(
agent_runtime_tool_input_text(input, &["repairOfDelegationId", "repair_of_delegation_id"]);
let repair_of_delegation_id =
(!repair_of_delegation_id.is_empty()).then_some(repair_of_delegation_id);
let required_visual_artifact: Option<&str> = None;
let required_visual_artifact = if editor_api_key_is_configured() {
match target_agent_id.as_str() {
"design-foundation" => Some("assets/ui-prototype.png"),
"art-asset-plan" => Some("assets/art-spritesheet.png"),
_ => None,
}
} else {
None
};
if repair_of_delegation_id.is_none()
&& required_visual_artifact.is_some_and(|required| {
!expected_artifacts
@@ -416,6 +416,39 @@ pub(in crate::agent) fn observe_agent_runtime_file_delete(
return agent_runtime_mutation_gate_failure_observation(root, "file.delete", &error);
}
}
if agent_id == "art-asset-plan" && path == "assets/art-spritesheet.png" {
let manifest = match read_existing_manifest_for_project(root) {
Ok(manifest) => manifest,
Err(error) => {
return AgentRuntimeToolObservation {
tool: "file.delete".to_string(),
status: "blocked".to_string(),
summary: "无法确认首版美术素材登记状态,未执行删除".to_string(),
detail: Some(redact_agent_runtime_project_paths(root, &error, 500)),
};
}
};
let registered_fixed_asset_exists = manifest.assets.iter().any(|asset| {
asset.local_path == "assets/art-spritesheet.png"
&& asset.kind == "art-spritesheet"
&& asset.media_type.starts_with("image/")
&& asset.source.kind == GameCreationAppAssetSourceKind::Canvas
&& resolve_local_project_path(root, &asset.local_path)
.ok()
.is_some_and(|path| path.is_file())
});
if registered_fixed_asset_exists {
return AgentRuntimeToolObservation {
tool: "file.delete".to_string(),
status: "blocked".to_string(),
summary: "首版美术素材已生成并登记,禁止删除固定正式产物".to_string(),
detail: Some(
"path=assets/art-spritesheet.png · 请复用现有画布资产并核对 assets/manifest.art.json,不得重复生成或扣费"
.to_string(),
),
};
}
}
if let Err(error) =
prepare_agent_runtime_project_mutation_locked(root, agent_id, run_id, "file.delete")
{
@@ -540,11 +540,6 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio
.or_else(|| input.get("replace_existing"))
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
let slice_count = input
.get("sliceCount")
.or_else(|| input.get("slice_count"))
.and_then(serde_json::Value::as_u64)
.map(|value| value as usize);
let requested_options = PlatformArtAssetGenerationOptions {
output_path: (!output_path.trim().is_empty()).then_some(output_path),
aspect_ratio,
@@ -552,9 +547,83 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio
asset_kind,
asset_label,
replace_existing,
slice_count,
};
let mut options = {
let canonical_options = match agent_id {
"art-director" => Some(PlatformArtAssetGenerationOptions {
output_path: Some(AGENT_RUNTIME_ART_SPEC_PATH.to_string()),
aspect_ratio: "1:1".to_string(),
image_size: "1K".to_string(),
asset_kind: "icon-spec".to_string(),
asset_label: "游戏统一视觉规范图".to_string(),
replace_existing: false,
}),
"design-foundation"
if requested_options
.output_path
.as_deref()
.is_some_and(design_foundation_ui_page_output_path_is_valid) =>
{
Some(PlatformArtAssetGenerationOptions {
output_path: requested_options.output_path.clone(),
aspect_ratio: "16:9".to_string(),
image_size: "2K".to_string(),
asset_kind: "ui-prototype".to_string(),
asset_label: if requested_options.asset_label.trim().is_empty() {
"游戏功能页面设计图".to_string()
} else {
requested_options.asset_label.clone()
},
replace_existing: false,
})
}
"design-foundation" => Some(PlatformArtAssetGenerationOptions {
output_path: Some("assets/ui-prototype.png".to_string()),
aspect_ratio: "16:9".to_string(),
image_size: "2K".to_string(),
asset_kind: "ui-prototype".to_string(),
asset_label: "游戏横屏界面原型图".to_string(),
replace_existing: false,
}),
"art-asset-plan" => Some(PlatformArtAssetGenerationOptions {
output_path: Some("assets/art-spritesheet.png".to_string()),
aspect_ratio: "1:1".to_string(),
image_size: "1K".to_string(),
asset_kind: "art-spritesheet".to_string(),
asset_label: "游戏首版核心美术素材".to_string(),
replace_existing: false,
}),
_ => None,
};
let mut options = if let Some(canonical) = canonical_options {
let mismatch = requested_options
.output_path
.as_deref()
.is_some_and(|value| Some(value) != canonical.output_path.as_deref())
|| (!requested_options.aspect_ratio.is_empty()
&& requested_options.aspect_ratio != canonical.aspect_ratio)
|| (!requested_options.image_size.is_empty()
&& requested_options.image_size != canonical.image_size)
|| (!requested_options.asset_kind.is_empty()
&& requested_options.asset_kind != canonical.asset_kind)
|| (!requested_options.asset_label.is_empty()
&& requested_options.asset_label != canonical.asset_label);
if mismatch {
return AgentRuntimeToolObservation {
tool: "canvas.asset_generate".to_string(),
status: "failed".to_string(),
summary: format!(
"图片产物型专业任务不能覆盖固定输出合同:outputPath={} · aspectRatio={} · imageSize={} · assetKind={} · assetLabel={}",
canonical.output_path.as_deref().unwrap_or("null"),
canonical.aspect_ratio,
canonical.image_size,
canonical.asset_kind,
canonical.asset_label,
),
detail: None,
};
}
canonical
} else {
let defaults = PlatformArtAssetGenerationOptions::default();
PlatformArtAssetGenerationOptions {
output_path: requested_options.output_path,
@@ -579,7 +648,6 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio
requested_options.asset_label
},
replace_existing,
slice_count,
}
};
options.replace_existing = replace_existing;
@@ -242,27 +242,6 @@ pub(crate) fn render_agc_skill_pack_index() -> Result<String, String> {
Ok(lines.join("\n"))
}
pub(crate) fn read_agc_skill_resource(resource: &str) -> Result<String, String> {
let manifest = validated_skill_pack_manifest()?;
let normalized = resource.trim().trim_start_matches('/').replace('\\', "/");
let (skill_name, relative) = normalized
.split_once('/')
.ok_or_else(|| "Skill 资源路径必须是 skill/file".to_string())?;
let entry = manifest
.skills
.iter()
.find(|entry| entry.name == skill_name)
.ok_or_else(|| "未登记的 AGC Skill 资源".to_string())?;
if !entry.files.iter().any(|file| file == relative) || !is_safe_skill_relative_path(relative) {
return Err("未登记或不安全的 AGC Skill 资源".to_string());
}
let bundled_path = format!("{skill_name}/{relative}");
let bytes =
bundled_skill_file(&bundled_path).ok_or_else(|| "AGC Skill 资源不存在".to_string())?;
let canonical = canonical_skill_text_bytes(&bundled_path, bytes)?;
String::from_utf8(canonical.into_owned()).map_err(|_| "AGC Skill 资源不是 UTF-8".to_string())
}
pub(crate) fn install_agc_skill_pack(isolated_os_home: &Path) -> Result<String, String> {
let manifest = validated_skill_pack_manifest()?;
let skills_root = isolated_os_home.join(".agents").join("skills");
@@ -1386,7 +1386,7 @@ fn runtime_tool_description(tool: &str) -> &'static str {
"preview.validate" => "用真实浏览器验证桌面和移动预览并保存证据。",
"image.inspect" => "让视觉模型检查一至两张项目内图片。",
"canvas.asset_generate" => {
"通过已配置的 External Editor API 按项目需求生成图片或图集并登记到画布、素材库和项目 assets;可使用已登记资源作为参考,也可通过 sliceCount 指定图集切片数量"
"通过已配置的 External Editor API 生成图片并登记到画布、素材库和项目 assets;art-director 先生成 icon-spec 规范图,ui-prototype 与透明 art-spritesheet 都固定复用该规范图;只有唯一返工委派可显式替换已登记正式图片"
}
"ui.workflow.run" => {
"先用 discover 从受控 game/ui-pages.json 或页面声明标记自动发现全部功能页面,再把已登记 ui-prototype 与每个页面的设计图桥接成独立 UI JSON State;可同时载入已登记图片、图标和项目字体,执行 Provider 结构识别、多树合并与分批组件绑定、回读阶段,并且只有所有页面已绑定且已应用到 game/ 后才允许 finalize。项目根目录由 Runtime 注入,模型不得传入宿主路径。"
@@ -1,5 +1,4 @@
use super::*;
use crate::agent::read_direct_project_chat_history_at;
use crate::ui_editor::resource::font::FontAsset;
use sha2::{Digest, Sha256};
use std::collections::{BTreeMap, HashSet};
@@ -4749,15 +4748,6 @@ pub(crate) fn read_local_conversation(
read_local_conversation_for_session_at(root, agent_id.as_deref(), session_id.as_deref())
}
#[tauri::command]
pub(crate) fn read_direct_project_conversation(
project_path: String,
) -> Result<LocalConversationResult, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "conversation.read")?;
read_direct_project_chat_history_at(root)
}
#[tauri::command]
pub(crate) fn append_local_conversation_message(
project_path: String,
@@ -4786,20 +4776,6 @@ pub(crate) fn append_local_conversation_message(
}
}
#[tauri::command]
pub(crate) fn append_direct_project_conversation_message(
project_path: String,
message: LocalConversationMessage,
message_id: Option<String>,
) -> Result<LocalConversationResult, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "conversation.write")?;
let item =
direct_project_local_message_item(&message.role, &message.content, message_id.as_deref())?;
append_direct_project_history_item_at(root, &item)?;
read_direct_project_chat_history_at(root)
}
#[tauri::command]
pub(crate) fn build_local_project_index(
project_path: String,
@@ -2484,8 +2484,6 @@ fn main() {
Ok(())
})
.invoke_handler(tauri::generate_handler![
start_game_creator_external_mcp,
stop_game_creator_external_mcp,
create_automatic_local_game_project,
init_local_game_project,
import_local_godot_project,
@@ -2591,9 +2589,7 @@ fn main() {
set_active_game_creator_agent_session,
archive_game_creator_agent_session,
read_local_conversation,
read_direct_project_conversation,
append_local_conversation_message,
append_direct_project_conversation_message,
build_local_project_index,
create_local_project_checkpoint,
export_local_project_package,
@@ -4318,12 +4318,12 @@ fn project_append_locks() -> &'static Mutex<BTreeMap<PathBuf, Arc<Mutex<()>>>> {
PROJECT_APPEND_LOCKS.get_or_init(|| Mutex::new(BTreeMap::new()))
}
pub(crate) struct ProjectAppendLock {
pub(super) struct ProjectAppendLock {
process_lock: Arc<Mutex<()>>,
os_lock_path: PathBuf,
}
pub(crate) struct ProjectAppendGuard<'a> {
pub(super) struct ProjectAppendGuard<'a> {
_process_guard: std::sync::MutexGuard<'a, ()>,
_os_lock: File,
}
@@ -4335,7 +4335,7 @@ impl ProjectAppendLock {
.map_err(|_| format!("获取{error_label}进程内锁失败:锁已损坏"))
}
pub(crate) fn lock(&self, error_label: &str) -> Result<ProjectAppendGuard<'_>, String> {
pub(super) fn lock(&self, error_label: &str) -> Result<ProjectAppendGuard<'_>, String> {
let process_guard = self
.process_lock
.lock()
@@ -4348,7 +4348,7 @@ impl ProjectAppendLock {
}
}
pub(crate) fn project_append_lock_for(path: &Path) -> Result<ProjectAppendLock, String> {
pub(super) fn project_append_lock_for(path: &Path) -> Result<ProjectAppendLock, String> {
let mut locks = project_append_locks()
.lock()
.map_err(|_| "获取本地追加写锁失败:锁已损坏".to_string())?;
@@ -4539,7 +4539,7 @@ fn try_open_project_append_os_lock(path: &Path, error_label: &str) -> Result<Opt
))
}
pub(crate) fn append_jsonl_line_unlocked(
pub(super) fn append_jsonl_line_unlocked(
path: &Path,
line: &str,
error_label: &str,
@@ -400,6 +400,12 @@ pub(crate) fn build_project_resource_graph(
.push(resource_ids[0].clone());
}
let resources_by_manifest_asset_id = resource_ids_by_manifest_asset
.iter()
.filter(|(_, resource_ids)| resource_ids.len() == 1)
.map(|(asset_id, resource_ids)| (asset_id.clone(), resource_ids[0].clone()))
.collect::<BTreeMap<_, _>>();
let mut unresolved_reference_resource_ids = BTreeSet::new();
let mut reference_edge_by_id = BTreeMap::<String, ProjectResourceReferenceEdge>::new();
for (asset_id, target_resource_ids) in &resource_ids_by_manifest_asset {
@@ -418,16 +424,32 @@ pub(crate) fn build_project_resource_graph(
.filter(|value| !value.is_empty())
.collect::<BTreeSet<_>>()
{
let source_candidates = resources_by_external_id
let mut source_candidates = resources_by_external_id
.get(external_reference_id)
.map(Vec::as_slice)
.unwrap_or(&[]);
.unwrap_or(&[])
.iter()
.collect::<BTreeSet<_>>();
if let Some(referenced_asset_id) = external_reference_id
.strip_prefix("local-asset:")
.map(str::trim)
.filter(|value| !value.is_empty())
{
if let Some(source_resource_id) =
resources_by_manifest_asset_id.get(referenced_asset_id)
{
source_candidates.insert(source_resource_id);
}
}
if source_candidates.len() != 1 {
unresolved_reference_resource_ids.insert(external_reference_id.to_string());
continue;
}
let source_resource_id = &source_candidates[0];
if !resource_by_id.contains_key(source_resource_id)
let source_resource_id = source_candidates
.iter()
.next()
.expect("a non-empty candidate set must have one resource");
if !resource_by_id.contains_key(source_resource_id.as_str())
|| !resource_by_id.contains_key(target_resource_id)
{
continue;
@@ -438,7 +460,7 @@ pub(crate) fn build_project_resource_graph(
ProjectResourceReferenceEdge {
id,
kind: "asset-reference".to_string(),
source_resource_id: source_resource_id.clone(),
source_resource_id: source_resource_id.to_string(),
target_resource_id: target_resource_id.clone(),
cyclic: false,
},
@@ -835,6 +857,75 @@ mod tests {
);
}
#[test]
fn graph_resolves_local_asset_reference_identities() {
let manifest = manifest(
Vec::new(),
vec![
asset("source-1", None, &[], None),
asset(
"derivative-1",
Some("local-asset:derivative-1"),
&["local-asset:source-1"],
None,
),
],
);
let graph = build_project_resource_graph(
&manifest,
vec![
resource("asset:source-1", Some("source-1"), None),
resource("asset:derivative-1", Some("derivative-1"), None),
],
&[],
false,
);
assert_eq!(graph.reference_edges.len(), 1);
assert_eq!(
graph.reference_edges[0].source_resource_id,
"asset:source-1"
);
assert_eq!(
graph.reference_edges[0].target_resource_id,
"asset:derivative-1"
);
assert!(graph.unresolved_reference_resource_ids.is_empty());
assert!(graph
.connection_index
.iter()
.any(|index| index.resource_id == "asset:derivative-1"
&& index.upstream_reference_resource_ids == vec!["asset:source-1"]));
}
#[test]
fn graph_resolves_local_asset_identity_without_ambiguous_remote_duplicate() {
let manifest = manifest(
Vec::new(),
vec![
asset("source-1", Some("external-source"), &[], None),
asset(
"derivative-1",
Some("local-asset:derivative-1"),
&["local-asset:source-1"],
None,
),
],
);
let graph = build_project_resource_graph(
&manifest,
vec![
resource("asset:source-1", Some("source-1"), None),
resource("asset:derivative-1", Some("derivative-1"), None),
],
&[],
false,
);
assert_eq!(graph.reference_edges.len(), 1);
assert!(graph.unresolved_reference_resource_ids.is_empty());
}
#[test]
fn graph_aggregates_flows_filters_missing_resources_and_detects_cycles_iteratively() {
let manifest = manifest(
@@ -3950,9 +3950,14 @@ fn commit_resource_edit_asset_internal(
})
.transpose()?;
let image_sequence_duration_ms = ledger.remote_sequence_duration_ms;
let asset_kind = if input.edit_kind == LocalProjectResourceEditKind::CharacterAnimation {
"character-animation".to_string()
} else {
source.asset_kind.clone()
};
let asset = GameCreationAppAssetManifestEntry {
id: asset_id.clone(),
kind: source.asset_kind.clone(),
kind: asset_kind,
media_type: staged_media_type.to_string(),
local_path: relative_path.clone(),
image_sequence_frames,
@@ -8967,6 +8972,72 @@ mod tests {
);
}
#[test]
fn character_animation_commit_uses_animation_asset_kind() {
let directory = tempfile::tempdir().expect("create animation commit fixture");
let root = directory.path();
init_local_game_project_at(root, PROJECT_ID, "角色动画提交类型测试")
.expect("initialize project");
let uploaded = upload_local_asset_at(
root,
"character-source.png",
"image/png",
&resource_editor_test_png(),
)
.expect("upload source image");
let manifest = read_existing_manifest_for_project(root).expect("read source manifest");
let source_asset = manifest
.assets
.iter()
.find(|asset| asset.id == uploaded.id)
.cloned()
.expect("find source image");
let mut request = input(
root,
Uuid::new_v4().to_string(),
LocalProjectResourceEditKind::CharacterAnimation,
format!("asset:{}", source_asset.id),
);
request.source_asset_id = Some(source_asset.id.clone());
request.source_path = Some(source_asset.local_path.clone());
request.source_media_type = Some(source_asset.media_type.clone());
request.source_subtype = Some(source_asset.kind.clone());
let source = resolve_resource_edit_source(
root,
&read_existing_manifest_for_project(root).expect("reread manifest"),
&request,
)
.expect("resolve animation source");
let mut ledger = ledger_for(&request, &source, ResourceEditLedgerPhase::MediaDownloaded);
ledger.staged_media_type = Some("video/mp4".to_string());
ledger.staged_extension = Some("mp4".to_string());
ledger.remote_sequence_duration_ms = Some(4_000);
write_resource_edit_staging(
root,
&request.operation_id,
b"\0\0\0\x18ftypisom\0\0\0\0isomiso2",
)
.expect("stage animation preview");
let result = commit_resource_edit_asset(
root,
&request,
&source,
&request.prompt,
&request.asset_name,
&mut ledger,
)
.expect("commit animation derivative");
let derivative = result.asset.expect("animation derivative");
assert_eq!(derivative.kind, "character-animation");
assert_eq!(
derivative.source.generation_kind.as_deref(),
Some("character-animation")
);
assert_eq!(derivative.image_sequence_duration_ms, Some(4_000));
}
#[test]
fn platform_session_switch_waits_until_local_asset_commit_finishes() {
let directory = tempfile::tempdir().expect("create commit account switch fixture");
@@ -1165,7 +1165,6 @@ async fn canonical_art_spec_and_ui_requests_use_the_shared_reference_chain() {
asset_kind: "ui-prototype".to_string(),
asset_label: "游戏横屏界面原型图".to_string(),
replace_existing: false,
slice_count: None,
},
)
.await;
@@ -4857,7 +4856,6 @@ fn ui_prototype_generation_uses_dedicated_prompt_and_art_spec() {
asset_kind: "ui-prototype".to_string(),
asset_label: "游戏横屏界面原型图".to_string(),
replace_existing: false,
slice_count: None,
};
let prompt = build_platform_art_asset_prompt(
"原创网格贪吃蛇:分数与状态 HUD、四类不同分值食物、开始、方向键/WASD、触控方向键、失败与重开",

Some files were not shown because too many files have changed in this diff Show More