Compare commits

..

1 Commits

Author SHA1 Message Date
kdletters b79ee96f7e 补齐容器预览的 AGC Router 密钥说明
- deploy/container/api-server.env.example 增加 AGC 官方 LLM Router 段,列明容器预览必须由 .env.secrets.local 提供的三个密钥
- 【开发运维】Jenkins容器预览部署控制面技术方案 补充预览 secrets 必含密钥与缺失时的失败表现
- 【开发运维】本地开发验证与生产运维 补充容器与 Jenkins 预览路径同样受 Router 启动校验约束
- project-memory pitfalls 补充容器预览缺少 Router 密钥时的排障特征
2026-09-14 19:52:27 +08:00
26 changed files with 114 additions and 525 deletions
@@ -1,8 +1,4 @@
use super::*;
use crate::ui_editor::persistence::{
generate_ui_design_code_at, GenerateUiDesignCodeInput, UI_DESIGN_DOC_ASSET_KIND,
UI_DESIGN_DOC_MEDIA_TYPE,
};
pub(crate) const MAX_DIRECT_CODEX_REFERENCES: usize = 32;
const MAX_DIRECT_CODEX_REFERENCE_ID_CHARS: usize = 200;
@@ -137,35 +133,7 @@ fn validate_resource_reference_id(value: &str) -> Result<String, String> {
Ok(resource_id.to_string())
}
/// Render the prompt context for a UI design asset.
///
/// Keep this separate from the generic resource renderer so UI-specific
/// instructions/metadata can evolve without changing other asset kinds.
fn render_ui_design_reference_line(
root: &Path,
manifest: &GameCreationAppManifest,
asset: &GameCreationAppAssetManifestEntry,
resource_id: &str,
label: &str,
local_path: &str,
source: &str,
) -> String {
let context = match generate_ui_design_code_at(GenerateUiDesignCodeInput {
project_path: root.to_string_lossy().into_owned(),
expected_project_id: manifest.project_id.clone(),
asset_id: resource_id.to_string(),
}) {
Ok(result) => format!("请先阅读生成的带有文档的代码片段: {}", result.relative_path),
Err(error) => format!("生成代码遇到错误{error}"),
};
format!(
"- 素材 ID{resource_id};名称:{label};类型:{};媒体类型:{};项目路径:{local_path};来源:{source}",
asset.kind, asset.media_type
) + "\n" + &context
}
fn render_resource_reference_line(
root: &Path,
manifest: &GameCreationAppManifest,
reference: &DirectCodexResourceReference,
) -> Result<String, String> {
@@ -181,19 +149,6 @@ fn render_resource_reference_line(
.unwrap_or_else(|| asset_display_label(asset));
let source = sanitize_reference_source(reference.source.as_deref())
.unwrap_or_else(|| "unknown".to_string());
let is_ui_design =
asset.kind == UI_DESIGN_DOC_ASSET_KIND && asset.media_type == UI_DESIGN_DOC_MEDIA_TYPE;
if is_ui_design {
return Ok(render_ui_design_reference_line(
root,
manifest,
asset,
&resource_id,
&label,
&local_path,
&source,
));
}
Ok(format!(
"- 素材 ID{resource_id};名称:{label};类型:{};媒体类型:{};项目路径:{local_path};来源:{source}",
asset.kind, asset.media_type
@@ -279,7 +234,7 @@ pub(crate) fn render_direct_codex_references_section(
for reference in references {
lines.push(match reference {
DirectCodexTurnReference::Resource(reference) => {
render_resource_reference_line(root, &manifest, reference)?
render_resource_reference_line(&manifest, reference)?
}
DirectCodexTurnReference::RuntimeRegion(reference) => {
render_runtime_region_reference_line(&manifest, reference)?
@@ -2144,10 +2144,7 @@ pub(crate) fn create_ui_design_resource(
let next_index = manifest
.assets
.iter()
.filter(|asset| {
asset.kind == crate::ui_editor::persistence::UI_DESIGN_DOC_ASSET_KIND
&& asset.media_type == crate::ui_editor::persistence::UI_DESIGN_DOC_MEDIA_TYPE
})
.filter(|asset| asset.kind == "UI")
.count()
+ 1;
let resource_name = format!("UI 设计 {next_index}");
@@ -2181,8 +2178,8 @@ pub(crate) fn create_ui_design_resource(
let asset = match register_local_asset_at(
root,
&relative_path,
crate::ui_editor::persistence::UI_DESIGN_DOC_ASSET_KIND,
crate::ui_editor::persistence::UI_DESIGN_DOC_MEDIA_TYPE,
"UI",
"application/json",
"generated",
GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Generated,
@@ -2031,8 +2031,8 @@ fn register_local_asset_keeps_explicit_category_when_kind_is_unchanged() {
let registered = register_local_asset_at(
&root,
"ui/UI 设计 1.json",
shared_contracts::game_creation_app::GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND,
shared_contracts::game_creation_app::GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE,
"UI",
"application/json",
"ui-workflow",
source(),
)
@@ -2053,8 +2053,8 @@ fn register_local_asset_keeps_explicit_category_when_kind_is_unchanged() {
register_local_asset_at(
&root,
"ui/UI 设计 1.json",
shared_contracts::game_creation_app::GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND,
shared_contracts::game_creation_app::GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE,
"UI",
"application/json",
"ui-workflow",
source(),
)
@@ -2063,10 +2063,7 @@ fn register_local_asset_keeps_explicit_category_when_kind_is_unchanged() {
let manifest: Value =
serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap())
.expect("manifest json");
assert_eq!(
manifest["assets"][0]["kind"],
shared_contracts::game_creation_app::GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND
);
assert_eq!(manifest["assets"][0]["kind"], "UI");
assert_eq!(manifest["assets"][0]["category"], "audio");
assert_eq!(manifest["assets"][0]["tags"], serde_json::json!(["界面"]));
@@ -2077,7 +2074,7 @@ fn register_local_asset_keeps_explicit_category_when_kind_is_unchanged() {
///
/// 这里的字面量与写入侧逐字一致:UI 设计资产是
/// `ui_editor/resource_bridge.rs` / `workflow.rs` / `persistence.rs` 的
/// `register_local_asset_at` 使用 UI 文档 kind/media 常量
/// `register_local_asset_at(root, path, "UI", "application/json", ...)`
/// 字体是 `commands.rs` 字体上传的 `register_local_asset_entry(root, path, "font", ...)`。
/// 只要别名表漏掉它们,真机资产就会永远停在「待归类」且读时自愈也救不回来。
#[test]
@@ -2102,8 +2099,8 @@ fn register_local_asset_derives_category_from_real_write_side_kinds() {
register_local_asset_at(
&root,
"ui/UI 设计 1.json",
shared_contracts::game_creation_app::GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND,
shared_contracts::game_creation_app::GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE,
"UI",
"application/json",
"ui-workflow",
source(),
)
@@ -2136,11 +2133,7 @@ fn register_local_asset_derives_category_from_real_write_side_kinds() {
assert_eq!(
categories,
vec![
(
shared_contracts::game_creation_app::GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND
.to_string(),
"ui-interaction".to_string(),
),
("UI".to_string(), "ui-interaction".to_string()),
("font".to_string(), "document".to_string()),
]
);
@@ -265,11 +265,11 @@ pub fn inspect_separation_recovery(
}
pub fn finalize_separation(root: &Path, asset_id: &str) -> Result<(), String> {
remove_separation_recovery_files(root, asset_id)
remove_separation_state(root, asset_id)
}
pub fn discard_separation_recovery(root: &Path, asset_id: &str) -> Result<(), String> {
remove_separation_recovery_files(root, asset_id)
remove_separation_state(root, asset_id)
}
fn remove_separation_state(root: &Path, asset_id: &str) -> Result<(), String> {
@@ -281,33 +281,6 @@ fn remove_separation_state(root: &Path, asset_id: &str) -> Result<(), String> {
}
}
fn remove_separation_recovery_files(root: &Path, asset_id: &str) -> Result<(), String> {
let sidecar = separation_sidecar_dir(root, asset_id)?;
remove_separation_state(root, asset_id)?;
let entries = match fs::read_dir(&sidecar) {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(error) => return Err(format!("读取 separation 临时文件失败:{error}")),
};
for entry in entries {
let entry = entry.map_err(|error| format!("读取 separation 临时文件失败:{error}"))?;
let name = entry.file_name();
let name = name.to_string_lossy();
if name.starts_with("processed-") || name.starts_with("binding-") {
let path = entry.path();
if entry
.file_type()
.map_err(|error| format!("检查 separation 临时文件失败:{error}"))?
.is_file()
{
fs::remove_file(&path)
.map_err(|error| format!("删除 separation 临时文件失败:{error}"))?;
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -19,10 +19,6 @@ use std::time::{SystemTime, UNIX_EPOCH};
use typed_floats::tf32::StrictlyPositiveFinite;
const UI_DESIGN_STATE_SCHEMA_VERSION: &str = "game-creator-ui-design-state.v1";
pub(crate) use shared_contracts::game_creation_app::{
GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND as UI_DESIGN_DOC_ASSET_KIND,
GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE as UI_DESIGN_DOC_MEDIA_TYPE,
};
const UI_DESIGN_STATE_MAX_BYTES: usize = 2 * 1024 * 1024;
const UI_DESIGN_CODE_MAX_BYTES: usize = UI_DESIGN_STATE_MAX_BYTES * 8;
const UI_DESIGN_STATE_MAX_IMAGES: usize = 4;
@@ -325,7 +321,7 @@ fn ui_design_asset(
.into_iter()
.find(|asset| asset.id == asset_id)
.ok_or_else(|| "UI 设计资源不存在".to_string())?;
if asset.kind != UI_DESIGN_DOC_ASSET_KIND || asset.media_type != UI_DESIGN_DOC_MEDIA_TYPE {
if asset.kind != "UI" || asset.media_type != "application/json" {
return Err("目标资源不是 UI 设计 JSON 资产".to_string());
}
normalize_relative_path(&asset.local_path)?;
@@ -819,8 +815,8 @@ mod tests {
let asset = register_local_asset_at(
directory.path(),
relative_path,
UI_DESIGN_DOC_ASSET_KIND,
UI_DESIGN_DOC_MEDIA_TYPE,
"UI",
"application/json",
"test",
GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Generated,
@@ -1,7 +1,4 @@
use crate::ui_editor::persistence::{
initialize_ui_design_state_with_source_image_at, UI_DESIGN_DOC_ASSET_KIND,
UI_DESIGN_DOC_MEDIA_TYPE,
};
use crate::ui_editor::persistence::initialize_ui_design_state_with_source_image_at;
use crate::{
acquire_project_write_lock, advance_agent_runtime_project_revision_locked,
enforce_project_permission_policy, read_existing_manifest_for_project,
@@ -92,8 +89,8 @@ pub(crate) fn ensure_ui_design_resource_for_prototype(
}
if let Some(asset) = manifest.assets.iter().find(|asset| {
asset.kind == UI_DESIGN_DOC_ASSET_KIND
&& asset.media_type == UI_DESIGN_DOC_MEDIA_TYPE
asset.kind == "UI"
&& asset.media_type == "application/json"
&& asset.source.reference_resource_ids.iter().any(|reference| {
source_reference_ids
.iter()
@@ -121,8 +118,8 @@ pub(crate) fn ensure_ui_design_resource_for_prototype(
let asset = match register_local_asset_at(
root,
&relative_path,
UI_DESIGN_DOC_ASSET_KIND,
UI_DESIGN_DOC_MEDIA_TYPE,
"UI",
"application/json",
"generated",
GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Generated,
@@ -196,9 +193,7 @@ fn next_ui_design_path(
let mut index = manifest
.assets
.iter()
.filter(|asset| {
asset.kind == UI_DESIGN_DOC_ASSET_KIND && asset.media_type == UI_DESIGN_DOC_MEDIA_TYPE
})
.filter(|asset| asset.kind == "UI")
.count()
+ 1;
loop {
@@ -286,9 +281,11 @@ mod tests {
};
let first = ensure_ui_design_resource_for_prototype(input.clone()).expect("bridge");
assert!(first.created);
assert_eq!(first.asset.kind, UI_DESIGN_DOC_ASSET_KIND);
assert_eq!(first.asset.kind, "UI");
// 写侧 → 分类的端到端断言:这条路径走的是与
// 写侧与 persistence/workflow 共用 UI 文档 kind/media 常量
// `workflow.rs` / `persistence.rs` 完全相同的 `register_local_asset_at(..., "UI", ...)`
// 别名表漏掉大写 `UI` 时,这里会落 unclassified(真机 8 条 UI 资产的表现),
// 且派生值本身就是 unclassified,读时自愈也救不回来。
assert_eq!(
first.asset.category,
GameCreationAppAssetCategory::UiInteraction
@@ -325,10 +322,7 @@ mod tests {
.manifest
.assets
.iter()
.filter(|asset| {
asset.kind == UI_DESIGN_DOC_ASSET_KIND
&& asset.media_type == UI_DESIGN_DOC_MEDIA_TYPE
})
.filter(|asset| asset.kind == "UI")
.count(),
1
);
@@ -7,7 +7,6 @@ use crate::ui_editor::layout::node::{Node, StageStatus};
use crate::ui_editor::persistence::{
initialize_ui_design_state_at, load_ui_design_state_at, save_ui_design_state_at,
LoadUiDesignStateInput, SaveUiDesignStateInput, SaveUiDesignStateResult,
UI_DESIGN_DOC_ASSET_KIND, UI_DESIGN_DOC_MEDIA_TYPE,
};
use crate::ui_editor::resource::font::FontAsset;
use crate::ui_editor::resource::sprite::{SpriteAsset, SpriteAssetMetadata, SpriteBorder};
@@ -684,8 +683,8 @@ fn find_page_ui_resource(
let Some(asset) = matches.into_iter().next() else {
return Ok(None);
};
if asset.kind != UI_DESIGN_DOC_ASSET_KIND
|| asset.media_type != UI_DESIGN_DOC_MEDIA_TYPE
if asset.kind != "UI"
|| asset.media_type != "application/json"
|| asset.local_path != workflow_relative_path(source, &page.page_id)
{
return Err(format!("页面 {} 的 UI workflow 资源身份冲突", page.page_id));
@@ -749,8 +748,8 @@ fn ensure_page_ui_resource(
let registered = register_local_asset_at(
root,
&relative_path,
UI_DESIGN_DOC_ASSET_KIND,
UI_DESIGN_DOC_MEDIA_TYPE,
"UI",
"application/json",
"ui-workflow",
GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Generated,
@@ -1180,7 +1179,7 @@ fn update_page_manifest_stage(root: &Path, asset_id: &str, stage: &str) -> Resul
.iter_mut()
.find(|asset| asset.id == asset_id)
.ok_or_else(|| format!("UI workflow 资源 {} 未登记", asset_id))?;
if asset.kind != UI_DESIGN_DOC_ASSET_KIND || asset.media_type != UI_DESIGN_DOC_MEDIA_TYPE {
if asset.kind != "UI" || asset.media_type != "application/json" {
return Err(format!("UI workflow 资源 {} 类型不匹配", asset_id));
}
let next_kind = format!("ui-workflow.{stage}");
@@ -53,7 +53,6 @@ import { createPortal, flushSync } from 'react-dom';
import { PlatformResourceFilterBar } from '../../../../../packages/shared/src/components/PlatformResourceFilterBar';
import { PlatformSegmentedTabs } from '../../../../../packages/shared/src/components/PlatformSegmentedTabs';
import {
GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE,
type GameCreationAppAssetManifestEntry,
gameCreationAppAssetTags,
type GameIterationVersion,
@@ -1254,10 +1253,7 @@ function ResourcePickerThumbnail({
if (mediaType.startsWith('image/')) {
return <Loader2 size={18} className="animate-spin" aria-hidden="true" />;
}
if (
kind === 'ui' ||
mediaType === GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE
) {
if (kind === 'ui' || mediaType === 'application/json') {
return <FileText size={18} aria-hidden="true" />;
}
return <ImageIcon size={18} aria-hidden="true" />;
@@ -2,10 +2,6 @@ import type {
GameCreationAppAssetManifestEntry,
GameCreationAppManifest,
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
import {
GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND,
GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE,
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
export type UiDesignResourceBridgeResult = {
asset: GameCreationAppAssetManifestEntry;
@@ -52,8 +48,8 @@ export function findLinkedUiDesignResource(
manifest.assets
.filter(
(asset) =>
asset.kind === GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND &&
asset.mediaType === GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE &&
asset.kind === 'UI' &&
asset.mediaType === 'application/json' &&
asset.source.referenceResourceIds?.some((reference) =>
referenceIds.has(reference),
),
@@ -73,10 +73,6 @@ import type {
GameIterationVersion,
ProjectResourceCanvasLayoutMode,
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
import {
GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND,
GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE,
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
import { ImageCanvasCharacterAnimationPanelView } from '../../../../../src/components/image-editor/ImageCanvasCharacterAnimationPanelView';
import type {
CanvasLayer,
@@ -4637,12 +4633,7 @@ export default function ProjectDevelopmentView({
passive: false,
});
return () => manager.removeEventListener('wheel', handleResourceBookWheel);
}, [
handleResourceBookWheel,
mode,
// UI 编辑器会卸载整个资源 manager;返回时 ref 指向新节点,必须重新绑定原生 wheel。
uiEditorRoute,
]);
}, [handleResourceBookWheel, mode]);
const handleResourceBookMainPointerDown = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
@@ -5044,8 +5035,8 @@ export default function ProjectDevelopmentView({
if (canvasOpenEpochRef.current !== openEpoch) return;
if (
result.manifest.projectId !== manifest.projectId ||
result.asset.kind !== GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND ||
result.asset.mediaType !== GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE
result.asset.kind !== 'UI' ||
result.asset.mediaType !== 'application/json'
) {
throw new Error('UI 编辑资源结果与当前项目不一致');
}
@@ -5116,8 +5107,8 @@ export default function ProjectDevelopmentView({
if (uiEditorRoute) return;
const completed = manifest.assets.find(
(asset) =>
asset.kind === GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND &&
asset.mediaType === GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE &&
asset.kind === 'UI' &&
asset.mediaType === 'application/json' &&
asset.source.generationKind === 'ui-workflow.completed',
);
if (
@@ -6816,7 +6807,7 @@ export default function ProjectDevelopmentView({
[manifest, selectedResource],
);
const selectedResourceOpensUiEditor =
selectedResource?.subtype === GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND ||
selectedResource?.subtype === 'UI' ||
selectedResource?.subtype === 'ui-prototype';
const selectedToolbarStyle = selectedResourceLayer
@@ -1212,15 +1212,7 @@ export function useUiEditorSession(
'自动切分素材结果已写入编辑器,但 State 保存失败;sidecar 已保留,可继续恢复。',
);
}
if (
backfillErrors.length > 0 ||
completedResult.problematic_nodes.length > 0
) {
if (completedResult.problematic_nodes.length > 0) {
backfillErrors.push(
`${completedResult.problematic_nodes.length} 个节点达到返工上限,需要人工处理`,
);
}
if (backfillErrors.length > 0) {
const recovery = await invoke<SeparationRecoveryDTO>(
'inspect_separation_recovery',
{ projectPath, assetId: resourceId },
@@ -1227,32 +1227,6 @@ async function openMainProject(projectPath: string) {
).not.toBeNull();
}
export function installResizeObserverStub() {
let observerCount = 0;
let observerDisconnected = false;
class TestResizeObserver {
constructor(readonly callback: ResizeObserverCallback) {
observerCount += 1;
}
observe() {}
unobserve() {}
disconnect() {
observerDisconnected = true;
}
}
Object.defineProperty(window, 'ResizeObserver', {
configurable: true,
value: TestResizeObserver,
});
return {
observerCount: () => observerCount,
observerDisconnected: () => observerDisconnected,
};
}
beforeEach(() => {
resetLlmModelCatalogCacheForTest();
vi.spyOn(clientApi, 'loadClientLlmModels').mockResolvedValue({
@@ -4,10 +4,6 @@ import type {
ProjectResourceCanvasLayout,
ProjectResourceCanvasPosition,
} from '../../../../packages/shared/src/contracts/gameCreationApp';
import {
GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND,
GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE,
} from '../../../../packages/shared/src/contracts/gameCreationApp';
import { RESOURCE_REFERENCE_INSERT_EVENT } from '../../src/features/project-workspace/resourceReferences';
import { RESOURCE_BOOK_OVERVIEW_STACK_LIMIT } from '../../src/view/project-development/resourceBookLayout';
import {
@@ -38,7 +34,6 @@ import {
GAME_CREATION_AGENT_RUN_SCHEMA_VERSION,
type GameCreationAgentRunTrace,
getResourceSelectButton,
installResizeObserverStub,
it,
mockRoleAgentReply,
openResourceFilterPanel,
@@ -4713,7 +4708,25 @@ export function registerProjectWorkbenchFoundationTests() {
manuallyPlaced: false,
},
];
const resizeObserver = installResizeObserverStub();
let observerCount = 0;
let observerDisconnected = false;
class TestResizeObserver {
constructor(readonly callback: ResizeObserverCallback) {
observerCount += 1;
}
observe() {}
unobserve() {}
disconnect() {
observerDisconnected = true;
}
}
Object.defineProperty(window, 'ResizeObserver', {
configurable: true,
value: TestResizeObserver,
});
const animationFrame = vi.spyOn(window, 'requestAnimationFrame');
const originalGetBoundingClientRect =
HTMLElement.prototype.getBoundingClientRect;
@@ -4805,7 +4818,7 @@ export function registerProjectWorkbenchFoundationTests() {
/^url\(#.+-asset-reference-arrow\)$/u,
);
});
expect(resizeObserver.observerCount()).toBe(1);
expect(observerCount).toBe(1);
expect(
overlay
.querySelector('[data-testid="resource-dependency-overlay-scene"]')
@@ -4852,7 +4865,7 @@ export function registerProjectWorkbenchFoundationTests() {
),
);
rendered.unmount();
expect(resizeObserver.observerDisconnected()).toBe(true);
expect(observerDisconnected).toBe(true);
expect(removeViewportListener).toHaveBeenCalledWith(
'scroll',
expect.any(Function),
@@ -5903,144 +5916,6 @@ export function registerProjectWorkbenchFoundationTests() {
);
});
it('restores resource canvas panning after returning from the UI editor', async () => {
installResizeObserverStub();
const manifest = createGameCreationAppManifest(
'workbench-ui-editor-return-pan',
'UI 编辑器返回平移测试',
);
manifest.assets = [
{
id: 'ui-design-resource',
kind: GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND,
mediaType: GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE,
localPath: 'assets/ui-design.json',
source: { kind: 'generated' },
},
];
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_graph') {
return resourceGraphForInputs(args);
}
if (command === 'read_local_project_resource_canvas_layout') {
return {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: manifest.projectId,
mode: args?.mode,
revision: 0,
positions: [],
updatedAt: 0,
};
}
if (command === 'update_local_project_resource_canvas_layout') {
return {
status: 'updated',
layout: {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: manifest.projectId,
mode: args?.mode,
revision: 1,
positions: args?.positions,
updatedAt: 1,
},
};
}
if (command === 'load_ui_design_state') {
return {
revision: 0,
state: {
ui_trees: [],
ui_design_images: {},
sprite_assets: {},
font_assets: {},
},
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
render(
React.createElement(ProjectDevelopmentView, {
projectName: manifest.name,
projectPath: '/tmp/workbench-ui-editor-return-pan',
manifest,
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
supervisor: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
}),
);
await openResourceBookCategory('UI 交互');
const canvas = await screen.findByRole('region', { name: 'UI 交互' });
const scene = document.querySelector<HTMLElement>(
'[data-resource-book-view="child"] .game-resource-book-scene',
);
const wheel = (target: HTMLElement, deltaY: number) => {
const event = new WheelEvent('wheel', {
bubbles: true,
cancelable: true,
deltaY,
clientX: 120,
clientY: 100,
});
act(() => target.dispatchEvent(event));
expect(event.defaultPrevented).toBe(true);
};
wheel(scene ?? canvas, 120);
const world = canvas.querySelector<HTMLElement>('[data-resource-viewport]');
expect(world?.getAttribute('data-resource-viewport')).toMatch(
/^(?!48,48,1)/u,
);
fireEvent.click(
screen.getByRole('button', {
name: '选中资源:UI 交互 ui-design.json',
}),
);
fireEvent.click(await screen.findByRole('button', { name: 'UI 编辑器' }));
await screen.findByRole('button', { name: '返回资源' });
fireEvent.click(screen.getByRole('button', { name: '返回资源' }));
await waitFor(() =>
expect(screen.queryByRole('button', { name: '返回资源' })).toBeNull(),
);
const restoredCanvas = await screen.findByRole('region', {
name: 'UI 交互',
});
const restoredWorld = restoredCanvas.querySelector<HTMLElement>(
'[data-resource-viewport]',
);
const beforeReturn = world?.getAttribute('data-resource-viewport');
const restoredBeforePan = restoredWorld?.getAttribute(
'data-resource-viewport',
);
expect(restoredBeforePan).toBe(beforeReturn);
const restoredScene = document.querySelector<HTMLElement>(
'[data-resource-book-view="child"] .game-resource-book-scene',
);
const restoredWheelEvent = new WheelEvent('wheel', {
bubbles: true,
cancelable: true,
deltaY: 120,
clientX: 120,
clientY: 100,
});
act(() =>
(restoredScene ?? restoredCanvas).dispatchEvent(restoredWheelEvent),
);
expect(restoredWheelEvent.defaultPrevented).toBe(true);
expect(restoredWorld?.getAttribute('data-resource-viewport')).not.toBe(
restoredBeforePan,
);
});
it('clears the resource preview cache and cancels the old preview scope on project switch', async () => {
// 60d8b8fbb 删掉了从未被写入的 `resourcePreviewVersionByResourceId` 死接线,原先钉
// 那条 prop 与它的清空语句的断言随之取消;用例真正要守的意图不变——项目切换必须把
@@ -10,7 +10,6 @@ import {
canonicalGameCreationAppAssetKind,
GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND,
GAME_CREATION_APP_CANONICAL_ASSET_KINDS,
GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND,
type GameCreationAppAssetCategory,
gameCreationAppAssetCategory,
gameCreationAppAssetCategoryForKind,
@@ -220,13 +219,8 @@ describe('真机出现的 kind 归类口径', () => {
canonical: 'icon',
category: 'ui-interaction',
},
// UI 文档与字体 kind,两者都必须落进明确栏目。
// 现役写入侧的大写字面量与字体 kind,两者都必须落进明确栏目。
{ kind: 'UI', canonical: 'ui-design', category: 'ui-interaction' },
{
kind: GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND,
canonical: GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND,
category: 'ui-interaction',
},
{ kind: 'font', canonical: 'document', category: 'document' },
// `Object.prototype` 上的键不是别名:TS 侧必须用 `Object.hasOwn` 挡住对象字面量的
// 原型命中,Rust 侧 match 字面量本来就落 `image`;这类极端输入两侧也必须一致。
@@ -283,15 +277,13 @@ describe('写侧 kind 字面量 → 分类的端到端口径', () => {
return kinds;
}
test('UI 设计写点统一使用文档资产常量,且它落 UI 交互而不是待归类', () => {
test('UI 设计写点仍直接写 `"UI"`,且它落 UI 交互而不是待归类', () => {
// 8 条真机 UI 资产是 `kind:"UI"` + `mediaType:"application/json"`
// 该 kind 不在别名表里时派生结果也是 unclassified,读时自愈同样救不回来。
for (const file of UI_EDITOR_WRITE_SITES) {
expect(readFileSync(file, 'utf8')).toContain('UI_DESIGN_DOC_ASSET_KIND');
expect(registeredKindLiterals(file)).toContain('UI');
}
expect(
gameCreationAppAssetCategoryForKind(
GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND,
),
).toBe('ui-interaction');
expect(gameCreationAppAssetCategoryForKind('UI')).toBe('ui-interaction');
});
test('UI 编辑器写点写出的每个 kind 都落明确栏目', () => {
@@ -4,10 +4,6 @@ import type {
GameCreationAppAssetManifestEntry,
GameCreationAppManifest,
} from '../../../packages/shared/src/contracts/gameCreationApp';
import {
GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND,
GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE,
} from '../../../packages/shared/src/contracts/gameCreationApp';
import {
ensureUiDesignResourceForPrototype,
findLinkedUiDesignResource,
@@ -41,8 +37,8 @@ describe('ui design resource bridge', () => {
const manifest = manifestWithPrototype();
manifest.assets.push({
id: 'unrelated-ui',
kind: GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND,
mediaType: GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE,
kind: 'UI',
mediaType: 'application/json',
localPath: 'ui/unrelated.json',
source: { kind: 'generated', referenceResourceIds: ['other'] },
imageSequenceFrames: null,
@@ -51,8 +47,8 @@ describe('ui design resource bridge', () => {
expect(findLinkedUiDesignResource(manifest, 'prototype-asset')).toBeNull();
manifest.assets.push({
id: 'linked-ui',
kind: GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND,
mediaType: GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE,
kind: 'UI',
mediaType: 'application/json',
localPath: 'ui/linked.json',
source: { kind: 'generated', referenceResourceIds: ['prototype-asset'] },
imageSequenceFrames: null,
@@ -68,8 +64,8 @@ describe('ui design resource bridge', () => {
manifest.assets[0]!.source.resourceId = 'canvas-resource-1';
manifest.assets.push({
id: 'workflow-ui',
kind: GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND,
mediaType: GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE,
kind: 'UI',
mediaType: 'application/json',
localPath: 'ui/workflow.json',
source: {
kind: 'generated',
@@ -89,8 +85,8 @@ describe('ui design resource bridge', () => {
manifest.assets.push(
{
id: 'bridge-ui',
kind: GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND,
mediaType: GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE,
kind: 'UI',
mediaType: 'application/json',
localPath: 'ui/UI 设计 1.json',
source: {
kind: 'generated',
@@ -101,8 +97,8 @@ describe('ui design resource bridge', () => {
},
{
id: 'completed-ui',
kind: GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND,
mediaType: GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE,
kind: 'UI',
mediaType: 'application/json',
localPath: 'ui/ui-workflow-page.json',
source: {
kind: 'generated',
@@ -122,8 +118,8 @@ describe('ui design resource bridge', () => {
const manifest = manifestWithPrototype();
const linked = {
id: 'linked-ui',
kind: GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND,
mediaType: GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE,
kind: 'UI',
mediaType: 'application/json',
localPath: 'ui/linked.json',
source: {
kind: 'generated' as const,
@@ -150,8 +146,8 @@ describe('ui design resource bridge', () => {
const result = {
asset: {
id: 'new-ui',
kind: GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND,
mediaType: GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE,
kind: 'UI',
mediaType: 'application/json',
localPath: 'ui/UI 设计 1.json',
source: {
kind: 'generated' as const,
+6
View File
@@ -65,5 +65,11 @@ GENARRATIVE_LLM_PROVIDER=openai-compatible
GENARRATIVE_LLM_BASE_URL=https://api.vectorengine.cn/v1
GENARRATIVE_LLM_API_KEY=
GENARRATIVE_LLM_MODEL=gpt-5.4-mini
# AGC 官方 LLM Routerapi / all 角色启动时硬校验官方 HTTPS 地址、固定模型和以下三个密钥,缺失直接拒绝启动。
# 容器预览的密钥来自镜像内 /srv/genarrative/.env.secrets.localJenkins 预览 secrets 副本),
# 该副本必须包含 GENARRATIVE_LLM_ROUTER_PROVISIONING_SECRET、
# GENARRATIVE_LLM_ROUTER_API_KEY_ENCRYPTION_SECRET 和 GENARRATIVE_LLM_ROUTER_ADMIN_TOKEN。
# 本文件只用于按实例显式覆盖;容器环境变量留空时仍由镜像内 .env.secrets.local 提供取值。
# GENARRATIVE_LLM_ROUTER_BASE_URL=https://router.genarrative.world/v1
WECHAT_MINIPROGRAM_MESSAGE_TOKEN=
WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY=
@@ -1,51 +0,0 @@
# 【实施计划】UI设计文档引用代码上下文
| 字段 | 值 |
| --- | --- |
| Version | 1 |
| Status | ready |
| Owner | Codex |
| Milestone | `docs/project-memory/plans/【里程碑】UI设计文档引用代码上下文-2026-09-14.md` |
## 修改边界
允许修改:
- UI Editor persistence/resource bridge/workflow/command 中的 UI JSON 文档 kind 常量与校验。
- `packages/shared` 的 canonical kind 与 Rust 对应映射。
- `agent/direct_codex_references.rs` 的 UI 引用 prompt 生成。
- 相关 Rust、TypeScript 测试和当前 UI workflow/AGC 文档。
- `decision-log.md` 的长期决策记录。
明确不修改:
- `ui-prototype` 图片生成和图片 workflow 语义。
- UI State schema、`render_ui_design_state_js` 输出格式和 `ui/generated-*.js` 路径规则。
- SpacetimeDB schema、External v1 OpenAPI、非 Direct Codex Supervisor 引用行为。
- 用户已有 `.env` 未提交修改。
## 实现顺序
1. 更新 UI workflow 主规范与项目决策,明确 `ui-design-doc``ui-prototype` 的身份边界。
2. 在 persistence 提取并导出 UI 文档 kind/media 常量,替换 Rust UI 文档校验。
3. 同步所有 UI JSON 资源生产者、workflow 校验、命令筛选、shared contract 与前端 bridge。
4. 在 Direct Codex 引用渲染中调用 `generate_ui_design_code_at`;成功追加生成文件相对路径,失败追加原始错误并继续发送。
5. 补充旧 kind、图片、成功、文档错误、多引用和 renderer 输出的测试。
6. 执行定向验证,复核 diff 中无 fallback、迁移或无关 `.env` 修改。
## 验证命令
1. `cargo fmt --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --check`
2. `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml ui_editor::persistence`
3. `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml direct_codex_references`
4. 相关 shared contract Vitest 测试与前端类型检查
5. `npm run check:doc-index`
6. `npm run check:encoding`
7. `git diff --check`
## 风险与回滚点
- `ui-prototype` 与 UI JSON 文档共用部分分类展示逻辑,需确保分类映射不会让图片进入文档分支。
- `generate_ui_design_code_at` 会持有项目写锁;多引用必须顺序调用,不能并行写同一项目。
- 生成失败继续发送是已确认语义;测试必须证明错误文本进入 prompt 而不是被转成聊天失败。
- 若发现生产者仍写入旧 `UI`,按开发阶段合同直接修生产者和 fixture,不增加运行时兼容分支。
@@ -1,49 +0,0 @@
# 【里程碑】UI设计文档引用代码上下文
| 字段 | 值 |
| --- | --- |
| Version | 1 |
| Status | in-progress |
| Date | 2026-09-14 |
| Parent Spec | `docs/【技术方案】UI工作流资源桥接与Runtime执行-2026-08-24.md` |
## 目标
将可编辑 UI JSON 文档统一识别为 `ui-design-doc`,并在 Direct Codex 聊天引用该文档时复用 UI Editor 现有代码导出流程,为 LLM 提供生成的、带文档注释的 JS 代码路径或原始生成错误。
## 范围
- UI JSON 文档资产身份固定为 `kind=ui-design-doc``mediaType=application/json`
- 复用 `ui_editor::persistence::generate_ui_design_code_at` 读取并校验文档、调用 `render_ui_design_state_js`、写入 `ui/generated-*.js`
- Direct Codex 引用文本保留稳定 manifest 元数据,并追加代码路径或 `生成代码遇到错误{error}`
- 同步 Rust/TypeScript 的 UI 文档生产者、消费者、分类映射和测试。
- `ui-prototype` 图片继续作为独立源图片类型。
## 不在范围内
- 不接受旧 `UI``ui``ui-design` 作为 UI 文档类型。
- 不增加兼容 fallback、数据迁移或旧数据转换逻辑。
- 不把 `ui-prototype` 图片当作 UI 文档,不从图片生成替代 JSON 文档代码。
- 不改变 UI State schema、renderer 输出格式、项目 revision 或 manifest 阶段。
- 不改变旧 Supervisor 非 Direct Codex 引用链路。
## 依赖与前置条件
- `generate_ui_design_code_at` 已存在并返回 `relative_path`
- `render_ui_design_state_js` 已生成包含文档注释的 JS 模块。
- Direct Codex 结构化引用已传递 `resourceId`manifest 是资源身份权威。
## 验收标准
- [ ] UI 编辑器 JSON 资源创建、加载、保存、workflow 校验和引用分支均只接受 `ui-design-doc + application/json`
- [ ] Direct Codex 成功引用 UI 文档时,prompt 追加 `请先阅读生成的带有文档的代码片段: {relative_path}`
- [ ] UI 文档生成失败时,prompt 保留原 metadata 并追加 `生成代码遇到错误{error}`,不走图片或其它文件 fallback。
- [ ] `ui-prototype` 图片引用不触发 UI 文档代码生成。
- [ ] 多个引用按顺序处理,单个 UI 文档失败不丢失其它引用。
- [ ] Rust/TypeScript kind 映射一致,旧 kind 不被隐式迁移。
## 证据要求
- 自动化:persistence、direct_codex_references、shared contract 定向测试;`cargo fmt --check``npm run check:encoding``git diff --check`
- 运行时:不要求真实 Provider;测试验证生成文件路径、renderer 文档注释和 prompt 注入结果。
- 边界:旧 kind、图片 kind、损坏 JSON、renderer 错误、多引用和路径来源均有测试。
@@ -8677,9 +8677,3 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
- 决策:复用判据收窄为**同一条写调用链(同一线程)重入**——按锁路径登记真实持锁线程,只有当前线程就是持锁线程时才返回 advisory guard;本进程其它线程的争用继续走有界等待与终态占用。自主游戏构建流水线的并行专家动作豁免保持不变;跨进程占用、残留回收、权限分类、等待预算和错误文案不变。
- 边界:锁定这些不变量的既有用例(`project_tools` / `command_runtime` / `parallel_actions` / `runtime_state` / `response_stream` / `direct_tool_bridge` / `ui_editor::persistence`)不得为了让锁语义通过而改写;用「同线程自持锁」模拟「另一个写者」的两条用例改为**在另一条线程持锁**,断言语义不变。同进程跨线程重入(持锁链在 `await` / `spawn_blocking` 后于其它线程再取锁)仍会等满预算,出现现场时按 2026-08-27 的既有处置改用 `*_locked` 入口,不放宽判据。
- 关联文档:[项目客户端占用锁收敛里程碑](../plans/【里程碑】项目客户端占用锁收敛-2026-09-14.md)、[踩坑记录](pitfalls.md)。
## 2026-09-14 Direct Codex 引用 UI 设计文档生成代码上下文
- 决策:UI Editor JSON 文档资产唯一使用 `kind:"ui-design-doc"``mediaType:"application/json"``ui-prototype` 保持图片语义,旧 `UI` / `ui` / `ui-design` 不作为该文档分支输入,不做 fallback 或迁移。
- 决策:Direct Codex 结构化资源引用命中该 kind 时,顺序调用 UI Editor persistence 的 `generate_ui_design_code_at`,生成 `ui/generated-*.js`,并把 `请先阅读生成的带有文档的代码片段: {relative_path}` 追加到当前 prompt。生成失败不阻断本轮引用,追加原始 `生成代码遇到错误{error}`,其它引用继续处理。
- 原因:复用 `html_renderer/mod.rs` 统一产物,确保 LLM 读取的代码包含 UI 节点元数据和文档注释;严格 kind + mediaType 判定避免图片资产误走代码生成。
@@ -1,13 +1,5 @@
# 踩坑与排障记录
## 2026-09-14 UI 编辑器返回后资源画布滚轮平移失效
- **现象**:资源管理打开 UI 编辑器再返回后,资源画布滚轮平移/缩放不再响应;返回前同一手势正常。
- **原因**:资源画布的非 passive `wheel` 监听绑定在 `resourceBookManagerRef` 当前 DOM 上,但 effect 只依赖 `handleResourceBookWheel``mode`。UI 编辑器切换会卸载旧 manager 并挂载新 manager,依赖不变导致新节点没有重新绑定监听。
- **处理**:将 `uiEditorRoute` 纳入 wheel effect 依赖,使进入/退出 UI 编辑器时先清理旧节点监听,再给返回后的新 manager 绑定同一处理器。
- **验证**`npx vitest run apps/ai-game-creator-shell/tests/appSurface.test.ts -t "restores resource canvas panning"`;回归用例覆盖打开栏目、wheel 平移、进入 UI 编辑器、返回并再次 wheel 平移。
- **关联**`apps/ai-game-creator-shell/src/view/project-development/index.tsx``apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts`
## 2026-09-14 AGC 壳 Rust 套件按「一片一 job」拆分,且分片必须自校验覆盖
- **现象**`AI game creator shell Rust tests` 一直是客户端 CI 的关键路径。run 2097 实测 15 分 27 秒,其中 `apps/ai-game-creator-shell/src-tauri` 的 bin target 单测(2466 条)一条 `cargo test -- --test-threads=1` 串行占 507 秒。
@@ -5205,6 +5197,7 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/`
- 外层 `start-tauri-dev.mjs` 应在启动 Tauri 前完成配套开发服务准备,并统一收束自有服务进程树;不要让冷编译和数据库发布挤占 Tauri 的前端就绪等待。自动发布必须保留数据库,不能靠清库解决启动问题。
- CLI 与 standalone 可能是两个独立软链接。必须同时核对 `spacetime --version``spacetimedb-standalone --version`,不能把 CLI 的版本记录当作宿主版本证明;PATH 中存在宿主时启动器检查两者一致。更换宿主前停机备份数据,按原目录启动,不通过清库处理版本错配。
- Router 配置缺失不应只在首次请求时报错。API/All 启动必须先校验官方地址、固定模型、provisioning secret、管理员 Token 和凭据加密密钥;否则服务看似 healthy,但登录后的 provisioning/模型调用才延迟失败。
- 容器预览没有独立的 Router 配置来源,密钥只能来自 Jenkins 预览 secrets 副本内置的 `.env.secrets.local`。新增启动期密钥校验时必须同步维护该副本并重建预览镜像,否则表现为 api-server 退出码 1、nginx 停在 `Created`、worker 反复报“模型定价服务身份尚未初始化”,而 systemd 生产路径仍然正常,容易误判成容器或数据库问题。
## 共享画布框选需要识别 world 的真实后代命中
@@ -55,6 +55,8 @@ Jenkins 节点上的预览 `.env.local` 与 secrets 只允许来自受控的 Jen
宿主固定目录应由 Jenkins 运行账号所有且权限为 `0700`,两个源文件权限均为 `0600`;缺失、不是普通文件、owner 不匹配或权限过宽时,预览构建必须失败关闭。任一源文件变更后必须重新构建并替换 API 与 worker 预览镜像,只重启容器不会刷新已内置的内容。容器启动时显式注入的运行环境变量优先级高于镜像内的 `.env.local``.env.secrets.local`,用于按实例覆盖非通用值。
预览 secrets 副本还必须包含 AGC 官方 Router 的三个密钥:`GENARRATIVE_LLM_ROUTER_PROVISIONING_SECRET``GENARRATIVE_LLM_ROUTER_API_KEY_ENCRYPTION_SECRET``GENARRATIVE_LLM_ROUTER_ADMIN_TOKEN`。容器 `api` / `all` 角色启动时会硬校验官方 HTTPS 地址、固定模型和这三个密钥,缺失时 api-server 立即以退出码 1 结束,`nginx` 停在 `Created``external-generation-worker` 因模型定价服务身份未初始化而反复重启。该失败发生在镜像构建和模块发布之后,因此新增任何启动期密钥校验时必须同步维护预览 secrets 副本并重建镜像,不能等预览页面报错再排查。
这种方案只隐藏构建传输过程,不能让内置后的 secrets 对镜像持有者保密:能读取、保存或运行 `api-runtime` 镜像的人可以提取该文件。因此该镜像只能留在当前受信任内网 Docker 主机,禁止 push 到公共或跨信任边界的 registry,也禁止通过 `docker save`/构建 artifact 导出传播。需要跨边界分发时必须改用不含 secrets 的镜像与运行时密钥注入。
## Jenkins 参数与产物
@@ -1221,7 +1221,7 @@ game-project/
## 2026-08-17 UI Editor 项目资源 State 持久化
- UI 编辑器使用唯一的 manifest `kind: "ui-design-doc"``mediaType: "application/json"` 资源,不接受旧 `UI` / `ui` / `ui-design`,也不增加 fallback 或迁移。资源文件固定为严格 `game-creator-ui-design-state.v1` JSON envelope`projectId``assetId`、每资源 `revision` 和 Rust 唯一源 `State`;旧空对象、未知字段、身份错配、超限、无效内部引用和不安全相对路径均失败关闭。内部引用校验同时覆盖 `Image.target_graphic -> sprite_assets``Text.font -> font_assets`,可选引用非空时必须命中同一 State 内已登记资源。
- UI 编辑器复用现有 manifest `kind: "UI"``mediaType: "application/json"` 资源,不增加平行 asset kind。资源文件固定为严格 `game-creator-ui-design-state.v1` JSON envelope`projectId``assetId`、每资源 `revision` 和 Rust 唯一源 `State`;旧空对象、未知字段、身份错配、超限、无效内部引用和不安全相对路径均失败关闭。内部引用校验同时覆盖 `Image.target_graphic -> sprite_assets``Text.font -> font_assets`,可选引用非空时必须命中同一 State 内已登记资源。
- Tauri 专用 load/save command 只接受项目路径、期望项目 ID、manifest asset ID 和(保存时)资源 revisionRust 按 manifest 解析受控本地路径并在项目写锁内做 CAS。相同 `State` 返回 unchanged 且不推进 project revision;不同内容安装并回读一致后才推进 revision,后续推进失败返回 `reconciliation-required`,不伪装为完整保存。
- UI 编辑器代码导出仅写入用户项目目录下的 `ui/generated-*.js` 派生文件,绝不推进项目 revision、UI State revision、manifest 阶段或 Runtime 验证门;写入失败只返回生成错误,不得把生成文件写入冒充为项目 mutation。
- UI State 原子安装保留最近一个可解析、canonical 的 `.previous` 恢复候选,作为最佳努力恢复来源;写入主文件前不把完整 State 语义校验重复执行一遍。主文件损坏时,恢复候选仍必须通过同一严格 schema、project/asset identity、revision、引用和 State 校验后才能安装;恢复安装与保存共用项目写锁,并在持锁后重新读取主文件,已有并发保存的有效新版本时直接返回而不安装旧副本。任一候选均不可信则停在加载错误,前端禁编辑和保存。新建 UI 资源先登记并安装合法 envelope,任一步失败补偿 manifest/文件,避免把空 JSON 留给资源卡。
@@ -969,3 +969,4 @@ node scripts/rebind-orphan-work-owners.mjs --in <exported-migration.json> --out
`scripts/deploy/maintenance-on.sh` 只允许把同目录临时普通文件原子替换到普通文件或尚不存在的 `page.html` / `enabled` 目标。目标只要是符号链接(包括指向目录的链接)或目录,脚本必须在替换前失败,不能跟随链接把临时文件移入链接目标,也不能打印“已进入维护模式”。`page_temp``marker_temp` 必须在 `set -u` 下安全初始化,清理 trap 必须在首次 `mktemp` 前生效;任一失败退出都不得在目标同级遗留 `page.html.tmp.*``enabled.tmp.*`,成功替换后则清空临时路径并解除 trap,不能误删已安装目标。跨平台实现继续使用 POSIX `mv -f`,安全语义由替换函数的目标类型门禁保证;修改后运行 `bash -n scripts/deploy/maintenance-on.sh``npm run check:maintenance-page`
api-server 启动时会硬校验 AGC 官方 Router 配置:API/All 角色必须使用官方 HTTPS 地址和固定模型,并配置 provisioning secret、Router 管理员 Token,以及专用加密 secret 或有效 JWT secret;缺失或不匹配直接拒绝启动。`test` 环境仅允许 loopback fixtureworker-only 角色不执行 Router 配置校验。
容器与 Jenkins 预览路径同样受该校验约束,且密钥只能来自预览 secrets 副本内置的 `.env.secrets.local`:缺少上述三个密钥时,预览的 api-server 容器启动即退出,`nginx``external-generation-worker` 连带失败。维护预览环境时必须先补齐这三个键并重建镜像。
@@ -2,12 +2,10 @@
## 目标
`ui-prototype``ui-design-doc` 是两种不同资源,不能通过修改投影 `subtype` 混为一种资源:
`ui-prototype``UI` 是两种不同资源,不能通过修改投影 `subtype` 混为一种资源:
- `ui-prototype`:Agent 生成并登记到画布的界面设计图片。
- `ui-design-doc`UI 编辑器使用的 `application/json` 资源,保存界面图、UI 树、组件绑定和 State revision。
UI 设计文档资产必须严格满足 `kind=ui-design-doc``mediaType=application/json`;旧 `UI``ui``ui-design` 不作为兼容输入,也不提供迁移或图片 fallback。
- `UI`UI 编辑器使用的 JSON 资源,保存界面图、UI 树、组件绑定和 State revision。
自然语言生成链路必须把前者桥接为后者,并持续让 manifest 成为客户端资源投影的权威来源。游戏场景的页面清单由 Runtime 自动发现,Agent 不得凭空猜测页面。
@@ -37,7 +35,7 @@ Agent 通过白名单工具 `ui.workflow.run` 发起工作流。项目路径由
处理规则:
1. `prepare` 为每个页面创建确定性的 `kind=ui-design-doc` JSON 资源,引用源 `ui-prototype` 和页面设计图,载入设计图尺寸与相对路径到 `ui_design_images`,并保存 State。
1. `prepare` 为每个页面创建确定性的 `kind=UI` JSON 资源,引用源 `ui-prototype` 和页面设计图,载入设计图尺寸与相对路径到 `ui_design_images`,并保存 State。
2. `recognize` 依次执行 Provider 多模态结构识别、现有多树合并器、最多每批 5 项的图片/图标组件绑定,并把已登记字体的安全元数据提供给绑定器;阶段分别持久化为 `structure-ready``merge-ready``binding-ready`,重复执行从最近真实阶段恢复。
3. `status` 只回读 State、页面阶段和 blockers,不推进项目 revision。
4. `finalize` 只接受 `game/` 下的真实 UTF-8 文件,写入与 UI State revision 绑定的应用标记;所有页面通过应用门禁后才返回 `asset-separation` 最终阶段路由。缺少页面、资源、组件或应用标记时拒绝伪造完成。
@@ -63,33 +61,15 @@ UI 编辑器的分离提示会对节点描述和返工备注做长度与控制
真实 Provider 鉴权失败时,Codex app-server 可能只返回 `codexErrorInfo=other`,而把上游 `401/403` 放在错误正文中。Runtime 必须从受控错误字段识别为 `codex-app-server-error:unauthorized`(公共摘要为 `codex-app-server-unauthorized`),只向公共运行记录暴露错误类别和指纹,不记录 Token 或上游原文。此错误不能伪造为 UI 工作流阶段完成;修复凭据后应从原有 run 的恢复边界重新执行。
## UI 设计文档代码引用
Direct Codex 聊天引用 `ui-design-doc` 时,复用 `ui_editor::persistence::generate_ui_design_code_at`。该入口只读取被引用的 JSON 文档,调用 `render_ui_design_state_js`,并写入既有 `ui/generated-*.js` 派生文件。
成功时在稳定 manifest 元数据后追加:
```text
请先阅读生成的带有文档的代码片段: {relative_path}
```
JSON 文档读取、State 校验或 renderer 失败时,在同一引用后追加原始错误:
```text
生成代码遇到错误{error}
```
错误不阻断本轮 Direct Codex prompt,其它引用继续按顺序处理;不从图片或其它文件 fallback,不把生成文件登记为正式 manifest 资产,也不推进项目 revision。
## 画布跳转与客户端更新
点击画布中的 `ui-prototype` 时,工作台调用 `ensure_ui_design_resource_for_prototype`
- 按 manifest asset id、`source.resourceId``source.assetObjectId` 识别已有关联,避免重复创建。
- 没有关联时原子创建 `ui/UI 设计 N.json`,登记 `kind=ui-design-doc``application/json`,并把原型图作为首张页面设计图载入 State。
- 没有关联时原子创建 `ui/UI 设计 N.json`,登记 `kind=UI``application/json`,并把原型图作为首张页面设计图载入 State。
- 成功后通过 `onManifestChange` 更新客户端资源投影,再打开 UI 编辑器;普通桥接从 `reference-analysis` 开始。
点击已有 `ui-design-doc` 资源直接打开 UI 编辑器。若 manifest 阶段为 `ui-workflow.completed`,工作台自动打开该资源的 `asset-separation` 阶段(最远步骤为 2),交给用户做最终检查和手动调整。
点击已有 `UI` 资源直接打开 UI 编辑器。若 manifest 阶段为 `ui-workflow.completed`,工作台自动打开该资源的 `asset-separation` 阶段(最远步骤为 2),交给用户做最终检查和手动调整。
自动切分达到返工上限的 problematic 节点会随分离 DTO 返回每节点的 `problem_history`,并由编辑器回写为 `component_status = NeedReview(...)``SeparationOverview` 只读取 UI State 中的状态来计数和定位;该结果仍按“已尽力完成”报告成功并执行既有 finalize,剩余节点由用户在概览定位后手动处理或再次发起分离。
@@ -480,12 +480,6 @@ export interface GameCreationAppAssetManifestEntry {
tags?: string[];
}
/** UI Editor JSON 文档资产的唯一 kind 与媒体类型。 */
export const GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND =
'ui-design-doc' as const;
export const GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE =
'application/json' as const;
export const GAME_CREATION_APP_CANONICAL_ASSET_KINDS = [
'image',
'scene',
@@ -495,7 +489,6 @@ export const GAME_CREATION_APP_CANONICAL_ASSET_KINDS = [
'icon-spritesheet',
'icon-spec',
'ui-design',
GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND,
'publication-material',
'spec',
'video',
@@ -549,7 +542,12 @@ const GAME_CREATION_APP_LEGACY_ASSET_KINDS: Record<
export function canonicalGameCreationAppAssetKind(
value: string,
): GameCreationAppCanonicalAssetKind {
/** kind 词汇大小写不敏感;UI Editor 文档写入侧使用 UI 文档常量。 */
/**
* kind 词汇大小写不敏感:UI 设计资产的现役写入侧写的是**大写** `"UI"`
* `src-tauri/src/ui_editor/resource_bridge.rs`、`workflow.rs`、`persistence.rs`)。
* 只按小写收口会让它落 `image` 兜底、再经 `image → unclassified` 永远停在「待归类」,
* 且派生值本身就是 `unclassified`,读时自愈也救不回来。
*/
const normalized = value.trim().toLowerCase();
/**
* `kind` 是外部输入,可能正好是 `Object.prototype` 上的键(`constructor` / `__proto__` /
@@ -596,7 +594,6 @@ export const GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND: Record<
'icon-spritesheet': 'ui-interaction',
'icon-spec': 'ui-interaction',
'ui-design': 'ui-interaction',
[GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND]: 'ui-interaction',
'publication-material': 'unclassified',
spec: 'document',
video: 'unclassified',

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