Feat: 完善 Game Agent 资源画板与图片精修链路 #176

Merged
kdletters merged 33 commits from codex/game-agent-resource-workbench into master 2026-08-24 16:27:33 +08:00
43 changed files with 13055 additions and 2197 deletions
@@ -64,6 +64,7 @@ const DIRECT_CODEX_ART_ASSET_PATHS: [&str; 3] = [
DIRECT_CODEX_BACKGROUND_ASSET_PATH,
DIRECT_CODEX_SPRITESHEET_ASSET_PATH,
];
const DIRECT_CODEX_ART_AGENT_ID: &str = "direct-codex-art";
const DIRECT_CODEX_GAME_OUTPUTS: [(&str, &str, &str); 3] = [
("game/index.html", "game-entry", "text/html"),
("game/style.css", "game-style", "text/css"),
@@ -1872,7 +1873,7 @@ fn direct_taonier_art_generation_runtime_context(
_ => return Err("直连美术生成请求包含未声明的输出路径".to_string()),
};
Ok(PlatformArtGenerationRuntimeContext {
agent_id: "direct-codex-art".to_string(),
agent_id: DIRECT_CODEX_ART_AGENT_ID.to_string(),
task_id: format!("direct-codex-art-{stage}"),
session_id: project_id,
run_id: stage.to_string(),
@@ -2607,6 +2608,7 @@ async fn recover_direct_taonier_spritesheet_read_only_at(
json_string_field(resource, "objectKey").as_deref(),
&asset_object_id,
)?;
emit_game_creator_manifest_invalidated(root, DIRECT_CODEX_ART_AGENT_ID);
emit_direct_game_creator_progress(
root,
"art.spritesheet.recovered",
@@ -2744,6 +2746,7 @@ async fn generate_direct_taonier_art_asset_at(
}
Err(error) => return Err(direct_taonier_art_generation_failure(asset_label, error)),
};
emit_game_creator_manifest_invalidated(root, DIRECT_CODEX_ART_AGENT_ID);
if generated.asset.local_path != output_path {
return Err(format!(
"陶泥儿美术包生成返回后未形成可用的已登记平台素材合同 {output_path},已终止代码生成"
@@ -261,10 +261,11 @@ pub(crate) use entrypoints::{
chat_with_game_creator_role_agent_stream_at,
chat_with_game_creator_role_agent_stream_for_session_at,
configure_game_creator_manifest_invalidation_event_sink, emit_direct_game_creator_progress,
emit_game_creator_agent_runtime_update, game_creator_agent_runtime_update_event,
generate_local_game_draft_at, install_game_creator_manifest_invalidation_event_sink,
read_game_creator_agent_runtime_at, read_game_creator_agent_runtime_for_session_at,
read_game_creator_agent_runtimes_at, set_game_creator_agent_runtime_update_app_handle,
emit_game_creator_agent_runtime_update, emit_game_creator_manifest_invalidated,
game_creator_agent_runtime_update_event, generate_local_game_draft_at,
install_game_creator_manifest_invalidation_event_sink, read_game_creator_agent_runtime_at,
read_game_creator_agent_runtime_for_session_at, read_game_creator_agent_runtimes_at,
set_game_creator_agent_runtime_update_app_handle,
start_game_creator_manifest_invalidation_event_sink,
validate_game_creator_manifest_invalidation_event_sink,
};
@@ -235,6 +235,18 @@ fn relay_game_creator_manifest_invalidation(root: &Path, agent_id: &str) -> Resu
.map_err(|error| format!("发送 manifest 失效事件失败:{error}"))
}
pub(crate) fn emit_game_creator_manifest_invalidated(root: &Path, agent_id: &str) {
let event = GameCreatorManifestInvalidatedEvent {
project_path: root.to_string_lossy().into_owned(),
agent_id: agent_id.to_string(),
};
if let Some(app) = GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.get() {
let _ = app.emit("game-creator-manifest-invalidated", event);
return;
}
let _ = relay_game_creator_manifest_invalidation(root, agent_id);
}
pub(crate) fn game_creator_agent_runtime_update_event(
root: &Path,
runtime: AgentRuntimeResult,
@@ -623,6 +623,45 @@ pub(crate) fn update_local_project_asset_canvas_draft(
update_asset_canvas_draft_at(&root, &input)
}
#[tauri::command]
pub(crate) async fn import_local_project_asset_canvas_images(
app: tauri::AppHandle,
input: ImportAssetCanvasImagesInput,
) -> Result<ImportAssetCanvasImagesResult, String> {
let root = validated_local_project_directory_path(input.project_path.trim())?;
let (sender, receiver) = tokio::sync::oneshot::channel();
let mut dialog = app
.dialog()
.file()
.set_title("导入图片")
.add_filter("图片", &["png", "jpg", "jpeg", "webp"]);
if let Some(window) = app.get_webview_window("client") {
dialog = dialog.set_parent(&window);
}
dialog.pick_files(move |paths| {
let _ = sender.send(paths);
});
let Some(paths) = receiver
.await
.map_err(|_| "图片文件选择器意外关闭".to_string())?
else {
return Ok(ImportAssetCanvasImagesResult::cancelled());
};
if paths.is_empty() {
return Ok(ImportAssetCanvasImagesResult::cancelled());
}
let paths = paths
.into_iter()
.map(|path| {
path.into_path()
.map_err(|error| format!("读取导入图片路径失败:{error}"))
})
.collect::<Result<Vec<_>, _>>()?;
tokio::task::spawn_blocking(move || import_asset_canvas_images_at(&root, &input, &paths))
.await
.map_err(|error| format!("图片导入任务异常结束:{error}"))?
}
#[tauri::command]
pub(crate) fn store_local_project_asset_canvas_media(
input: StoreAssetCanvasMediaInput,
@@ -639,6 +678,14 @@ pub(crate) fn stage_local_project_asset_canvas_image(
stage_asset_canvas_image_at(&root, &input)
}
#[tauri::command]
pub(crate) async fn archive_failed_local_project_asset_canvas_generation(
input: ArchiveAssetCanvasGenerationInput,
) -> Result<ArchiveAssetCanvasGenerationResult, String> {
let root = validated_local_project_directory_path(input.project_path.trim())?;
archive_failed_asset_canvas_generation_at(&root, &input).await
}
#[tauri::command]
pub(crate) async fn generate_local_project_asset_canvas_image(
app: tauri::AppHandle,
@@ -662,6 +709,14 @@ pub(crate) async fn generate_local_project_asset_canvas_image(
Ok(execution.result)
}
#[tauri::command]
pub(crate) async fn finalize_local_project_asset_canvas_generation_failure(
input: FinalizeAssetCanvasGenerationFailureInput,
) -> Result<FinalizeAssetCanvasGenerationFailureResult, String> {
let root = validated_local_project_directory_path(input.project_path.trim())?;
finalize_asset_canvas_generation_failure_at(&root, &input).await
}
#[tauri::command]
pub(crate) async fn recover_local_project_asset_canvas_generations(
app: tauri::AppHandle,
@@ -728,6 +783,25 @@ pub(crate) fn commit_local_project_asset(
Ok(execution.result)
}
#[tauri::command]
pub(crate) fn commit_local_project_asset_canvas_candidate(
app: tauri::AppHandle,
input: CommitAssetCanvasCandidateInput,
) -> Result<CommitAssetCanvasResult, String> {
let root = validated_local_project_directory_path(input.project_path.trim())?;
let execution = commit_asset_canvas_candidate_at(&root, &input)?;
if let Some(event) = execution.event.as_ref() {
publish_asset_canvas_event_after_commit_at(&root, event, |payload| {
app.emit(
"game-creator-local-asset-committed",
asset_canvas_committed_public_event(payload),
)
.map_err(|error| error.to_string())
});
}
Ok(execution.result)
}
#[tauri::command]
pub(crate) fn recover_local_project_asset_canvas_transactions(
app: tauri::AppHandle,
@@ -24,6 +24,8 @@ pub(crate) struct LocalProjectImagePreview {
pub(crate) path: String,
pub(crate) media_type: String,
pub(crate) byte_len: u64,
pub(crate) pixel_width: u32,
pub(crate) pixel_height: u32,
pub(crate) data_url: String,
}
@@ -32,6 +34,8 @@ pub(crate) struct AgentRuntimeInspectionImage {
pub(crate) sha256: String,
pub(crate) byte_len: u64,
pub(crate) media_type: &'static str,
pixel_width: u32,
pixel_height: u32,
bytes: Vec<u8>,
}
@@ -86,6 +90,8 @@ pub(crate) fn load_local_project_image_preview_with_cancellation(
path: image.relative_path.clone(),
media_type: image.media_type.to_string(),
byte_len: image.byte_len,
pixel_width: image.pixel_width,
pixel_height: image.pixel_height,
data_url: image.data_url_with_cancellation(cancellation)?,
})
}
@@ -360,6 +366,8 @@ fn read_agent_runtime_inspection_image_with_cancellation(
sha256,
byte_len: bytes.len() as u64,
media_type,
pixel_width: width,
pixel_height: height,
bytes,
})
}
@@ -2366,8 +2366,11 @@ fn main() {
read_local_project_asset_canvas_draft,
discover_local_project_asset_canvas_draft,
update_local_project_asset_canvas_draft,
import_local_project_asset_canvas_images,
store_local_project_asset_canvas_media,
stage_local_project_asset_canvas_image,
finalize_local_project_asset_canvas_generation_failure,
archive_failed_local_project_asset_canvas_generation,
generate_local_project_asset_canvas_image,
recover_local_project_asset_canvas_generations,
confirm_local_project_asset_canvas_generation_service_identity,
@@ -2375,6 +2378,7 @@ fn main() {
discard_local_project_asset_canvas_draft,
recover_local_project_asset_canvas_transactions,
commit_local_project_asset,
commit_local_project_asset_canvas_candidate,
get_local_game_project_revision,
get_local_game_manifest
])
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -14,6 +14,8 @@ use std::path::Path;
const PROJECT_TEXT_PREVIEW_MAX_FILE_BYTES: u64 = 2 * 1024 * 1024;
const PROJECT_MEDIA_PREVIEW_MAX_FILE_BYTES: u64 = 32 * 1024 * 1024;
const PROJECT_RESOURCE_PREVIEW_READ_CHUNK_BYTES: usize = 64 * 1024;
const PROJECT_MEDIA_PREVIEW_MAX_DIMENSION: u32 = 8_192;
const PROJECT_MEDIA_PREVIEW_MAX_PIXELS: u64 = 32 * 1024 * 1024;
#[derive(Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
@@ -30,6 +32,10 @@ pub(crate) struct LocalProjectMediaPreview {
pub(crate) path: String,
pub(crate) media_type: String,
pub(crate) byte_len: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) pixel_width: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) pixel_height: Option<u32>,
pub(crate) data_url: String,
}
@@ -196,14 +202,135 @@ pub(crate) fn load_local_project_media_preview_with_cancellation(
cancellation.check()?;
let media_type = detect_project_media_type(&normalized, &bytes, kind)?;
cancellation.check()?;
let dimensions = (kind == ProjectMediaPreviewKind::Art)
.then(|| detect_project_art_dimensions(&bytes, media_type))
.flatten();
Ok(LocalProjectMediaPreview {
path: normalized,
media_type: media_type.to_string(),
byte_len: bytes.len() as u64,
pixel_width: dimensions.map(|(width, _)| width),
pixel_height: dimensions.map(|(_, height)| height),
data_url: encode_project_resource_preview_data_url(media_type, &bytes, cancellation)?,
})
}
fn detect_project_art_dimensions(bytes: &[u8], media_type: &str) -> Option<(u32, u32)> {
let dimensions = match media_type {
"image/gif" => detect_gif_dimensions(bytes),
"image/bmp" => detect_bmp_dimensions(bytes),
"image/avif" => detect_ispe_dimensions(bytes),
"image/svg+xml" => detect_svg_dimensions(bytes),
_ => None,
}?;
let (width, height) = dimensions;
let pixels = u64::from(width).checked_mul(u64::from(height))?;
if width == 0
|| height == 0
|| width > PROJECT_MEDIA_PREVIEW_MAX_DIMENSION
|| height > PROJECT_MEDIA_PREVIEW_MAX_DIMENSION
|| pixels > PROJECT_MEDIA_PREVIEW_MAX_PIXELS
{
return None;
}
Some((width, height))
}
fn detect_gif_dimensions(bytes: &[u8]) -> Option<(u32, u32)> {
if bytes.len() < 10 || !matches!(&bytes[..6], b"GIF87a" | b"GIF89a") {
return None;
}
Some((
u32::from(u16::from_le_bytes([bytes[6], bytes[7]])),
u32::from(u16::from_le_bytes([bytes[8], bytes[9]])),
))
}
fn detect_bmp_dimensions(bytes: &[u8]) -> Option<(u32, u32)> {
if bytes.len() < 26 || &bytes[..2] != b"BM" {
return None;
}
let width = i32::from_le_bytes(bytes[18..22].try_into().ok()?);
let height = i32::from_le_bytes(bytes[22..26].try_into().ok()?);
Some((width.unsigned_abs(), height.unsigned_abs()))
}
fn detect_ispe_dimensions(bytes: &[u8]) -> Option<(u32, u32)> {
let marker = b"ispe";
let index = bytes
.windows(marker.len())
.position(|window| window == marker)?;
if index < 4 || index.checked_add(16)? > bytes.len() {
return None;
}
Some((
u32::from_be_bytes(bytes[index + 8..index + 12].try_into().ok()?),
u32::from_be_bytes(bytes[index + 12..index + 16].try_into().ok()?),
))
}
fn detect_svg_dimensions(bytes: &[u8]) -> Option<(u32, u32)> {
let text = std::str::from_utf8(bytes).ok()?;
let lower = text.to_ascii_lowercase();
let start = lower.find("<svg")?;
let end = lower[start..].find('>')? + start;
let root = &text[start..end];
let width = svg_length_attribute(root, "width");
let height = svg_length_attribute(root, "height");
if let (Some(width), Some(height)) = (width, height) {
return Some((width, height));
}
let view_box = svg_attribute(root, "viewbox")?;
let mut values = view_box
.split(|character: char| character.is_ascii_whitespace() || character == ',')
.filter_map(|value| value.parse::<f64>().ok());
let _min_x = values.next()?;
let _min_y = values.next()?;
let width = values.next()?;
let height = values.next()?;
Some((safe_svg_dimension(width)?, safe_svg_dimension(height)?))
}
fn svg_length_attribute(root: &str, name: &str) -> Option<u32> {
let value = svg_attribute(root, name)?;
let value = value.trim().strip_suffix("px").unwrap_or(value.trim());
safe_svg_dimension(value.parse::<f64>().ok()?)
}
fn svg_attribute<'a>(root: &'a str, name: &str) -> Option<&'a str> {
let lower = root.to_ascii_lowercase();
let mut offset = 0;
while let Some(relative) = lower[offset..].find(name) {
let start = offset + relative;
let before = start
.checked_sub(1)
.and_then(|index| lower.as_bytes().get(index));
let after = lower.as_bytes().get(start + name.len());
if before.is_some_and(|byte| byte.is_ascii_whitespace())
&& after.is_some_and(|byte| *byte == b'=' || byte.is_ascii_whitespace())
{
let rest = root[start + name.len()..].trim_start();
let rest = rest.strip_prefix('=')?.trim_start();
let quote = rest.chars().next()?;
if quote == '\'' || quote == '"' {
let value = &rest[quote.len_utf8()..];
let end = value.find(quote)?;
return Some(&value[..end]);
}
}
offset = start + name.len();
}
None
}
fn safe_svg_dimension(value: f64) -> Option<u32> {
if !value.is_finite() || value <= 0.0 || value > f64::from(PROJECT_MEDIA_PREVIEW_MAX_DIMENSION)
{
return None;
}
Some(value.round() as u32)
}
fn encode_project_resource_preview_data_url(
media_type: &str,
bytes: &[u8],
@@ -539,6 +666,8 @@ mod tests {
)
.expect("safe svg");
assert_eq!(preview.media_type, "image/svg+xml");
assert_eq!(preview.pixel_width, None);
assert_eq!(preview.pixel_height, None);
assert!(preview.data_url.starts_with("data:image/svg+xml;base64,"));
assert!(load_local_project_media_preview(
root.path(),
@@ -554,6 +683,54 @@ mod tests {
.is_err());
}
#[test]
fn media_preview_reports_safe_dimensions_for_extended_art_images() {
let root = tempfile::tempdir().expect("temp root");
fs::create_dir_all(root.path().join("assets")).expect("assets dir");
let mut gif = b"GIF89a".to_vec();
gif.extend_from_slice(&320_u16.to_le_bytes());
gif.extend_from_slice(&180_u16.to_le_bytes());
gif.extend_from_slice(&[0; 4]);
fs::write(root.path().join("assets/scene.gif"), gif).expect("gif");
let mut bmp = vec![0_u8; 26];
bmp[..2].copy_from_slice(b"BM");
bmp[18..22].copy_from_slice(&640_i32.to_le_bytes());
bmp[22..26].copy_from_slice(&(-360_i32).to_le_bytes());
fs::write(root.path().join("assets/scene.bmp"), bmp).expect("bmp");
let mut avif = vec![0_u8; 32];
avif[4..8].copy_from_slice(b"ftyp");
avif[8..12].copy_from_slice(b"avif");
avif[16..20].copy_from_slice(b"ispe");
avif[24..28].copy_from_slice(&1920_u32.to_be_bytes());
avif[28..32].copy_from_slice(&1080_u32.to_be_bytes());
fs::write(root.path().join("assets/scene.avif"), avif).expect("avif");
fs::write(
root.path().join("assets/scene.svg"),
r#"<svg xmlns="http://www.w3.org/2000/svg" width="800" height="600"><path d="M0 0"/></svg>"#,
)
.expect("svg");
for (path, expected_type, expected_dimensions) in [
("assets/scene.gif", "image/gif", Some((320, 180))),
("assets/scene.bmp", "image/bmp", Some((640, 360))),
("assets/scene.avif", "image/avif", Some((1920, 1080))),
("assets/scene.svg", "image/svg+xml", Some((800, 600))),
] {
let preview =
load_local_project_media_preview(root.path(), path, ProjectMediaPreviewKind::Art)
.expect("extended image preview");
assert_eq!(preview.media_type, expected_type);
assert_eq!(
preview.pixel_width.zip(preview.pixel_height),
expected_dimensions
);
}
}
#[cfg(unix)]
#[test]
fn resource_preview_rejects_symlink_and_hardlink_files() {
@@ -105,6 +105,33 @@ fn manifest_invalidation_sink_isolation_relays_non_supervisor_runtime_update() {
fs::remove_dir_all(root).ok();
}
#[test]
fn direct_codex_art_commit_relays_standalone_manifest_invalidation() {
let sink_guard = acquire_game_creator_manifest_invalidation_event_sink_test_guard();
let root = unique_project_path();
let relay_listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0))
.expect("bind direct Codex manifest invalidation relay fixture");
let relay_port = relay_listener
.local_addr()
.expect("read direct Codex manifest invalidation relay fixture address")
.port();
let relay_token = "c".repeat(64);
sink_guard
.configure(relay_port, &relay_token)
.expect("configure direct Codex manifest invalidation relay fixture");
emit_game_creator_manifest_invalidated(&root, "direct-codex-art");
let relay_payload = read_manifest_invalidation_relay_payload_with_deadline(&relay_listener)
.expect("receive direct Codex manifest invalidation relay within deadline");
let relay: GameCreatorManifestInvalidationRelayEnvelope =
serde_json::from_slice(&relay_payload)
.expect("parse direct Codex manifest invalidation relay");
assert_eq!(relay.token, relay_token);
assert_eq!(relay.event.project_path, root.to_string_lossy());
assert_eq!(relay.event.agent_id, "direct-codex-art");
}
#[test]
fn manifest_invalidation_sink_isolation_bounds_timeouts_and_cleans_up_with_raii() {
let cleanup_listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0))
@@ -6142,6 +6142,11 @@ fn local_project_image_preview_obeys_auto_file_read_policy() {
read_local_project_image_preview_at(&project_path, "assets/preview.png", &cancellation)
.expect("auto preview");
assert_eq!(preview.media_type, "image/png");
assert_eq!(preview.pixel_width, 1);
assert_eq!(preview.pixel_height, 1);
let serialized_preview = serde_json::to_value(&preview).expect("serialize image preview");
assert_eq!(serialized_preview["pixelWidth"], 1);
assert_eq!(serialized_preview["pixelHeight"], 1);
fs::write(root.join("assets/unregistered.png"), &preview_bytes).expect("unregistered image");
let unregistered_error = read_local_project_image_preview_at(
+17 -10
View File
@@ -6412,14 +6412,21 @@ export function App({
);
}
} finally {
setChatAgentBusy(false);
const activeTurn = activeDirectCodexTurnRef.current;
if (
!activeTurn ||
(activeTurn.projectPath === directProjectPath &&
activeTurn.turnId === clientTurnId)
) {
resetDirectCodexTurn();
try {
if (localProjectPathRef.current === directProjectPath) {
await refreshManifest(directProjectPath);
}
} finally {
setChatAgentBusy(false);
setDirectCodexProgress('');
const activeTurn = activeDirectCodexTurnRef.current;
if (
!activeTurn ||
(activeTurn.projectPath === directProjectPath &&
activeTurn.turnId === clientTurnId)
) {
resetDirectCodexTurn();
}
}
}
return;
@@ -11565,8 +11572,8 @@ export function App({
);
const projectSupervisorHasConversationControls = Boolean(
projectSupervisorRuntime?.pendingToolAction ||
projectSupervisorRuntime?.userInputRequest ||
projectSupervisorNeedsUserInput,
projectSupervisorRuntime?.userInputRequest ||
projectSupervisorNeedsUserInput,
);
const visibleAgentConversationMessages = latestVisibleItems(
agentConversationMessages,
+24 -6
View File
@@ -365,7 +365,11 @@ export interface AgentRuntimeResult {
}
export type AgentRuntimeResponseStreamStatus =
'streaming' | 'ready' | 'committed' | 'discarded' | 'failed';
| 'streaming'
| 'ready'
| 'committed'
| 'discarded'
| 'failed';
export interface AgentRuntimeResponseStream {
schemaVersion: string;
@@ -486,13 +490,22 @@ export interface GameCreatorAgentLlmConfigStatus {
}
export type GameCreatorLlmApiKind =
'openai_responses' | 'openai_chat' | 'anthropic';
| 'openai_responses'
| 'openai_chat'
| 'anthropic';
export type GameCreatorAgentMode =
'codex_app_server' | 'codex_cli' | 'provider';
| 'codex_app_server'
| 'codex_cli'
| 'provider';
export type RuntimeLlmProviderPresetId =
'custom' | 'openai' | 'deepseek' | 'anthropic' | 'ark';
| 'custom'
| 'openai'
| 'deepseek'
| 'anthropic'
| 'ark';
export type RuntimeAgentLlmProviderPresetId =
'inherit' | RuntimeLlmProviderPresetId;
| 'inherit'
| RuntimeLlmProviderPresetId;
export interface GameCreatorLlmConfig {
apiKey: string;
@@ -795,7 +808,12 @@ export interface AgentProgressEvent {
}
export type GameCreatorDirectTurnUpdateStatus =
'accepted' | 'running' | 'streaming' | 'finalizing' | 'completed' | 'failed';
| 'accepted'
| 'running'
| 'streaming'
| 'finalizing'
| 'completed'
| 'failed';
export type GameCreatorDirectTurnActivity =
| 'request-accepted'
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,82 @@
const COMMIT_ID_SUFFIX =
/--[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const WINDOWS_RESERVED_NAMES = new Set([
'CON',
'PRN',
'AUX',
'NUL',
'COM1',
'COM2',
'COM3',
'COM4',
'COM5',
'COM6',
'COM7',
'COM8',
'COM9',
'LPT1',
'LPT2',
'LPT3',
'LPT4',
'LPT5',
'LPT6',
'LPT7',
'LPT8',
'LPT9',
]);
const FORBIDDEN_FILE_NAME_CHARACTERS = '/\\:*?"<>|';
function isForbiddenFileNameCharacter(value: string) {
const code = value.codePointAt(0) ?? 0;
return (
code <= 0x1f ||
code === 0x7f ||
FORBIDDEN_FILE_NAME_CHARACTERS.includes(value)
);
}
function sanitizeAssetBaseName(value: string, fallback: string) {
const normalized = value
.normalize('NFC')
.split('')
.filter((character) => !isForbiddenFileNameCharacter(character))
.join('');
const bounded = Array.from(normalized)
.slice(0, 80)
.join('')
.replace(/[. ]+$/u, '');
if (
bounded.length === 0 ||
WINDOWS_RESERVED_NAMES.has(bounded.split('.')[0]?.toUpperCase() ?? '')
) {
return fallback;
}
return bounded;
}
export function canonicalAssetBaseName(
localPath: string,
fallback = '画布素材',
) {
const fileName = localPath.split(/[\\/]/u).filter(Boolean).pop() ?? '';
let stem = fileName.replace(/\.[^.]+$/u, '');
let next = stem.replace(COMMIT_ID_SUFFIX, '');
while (next !== stem) {
stem = next;
next = stem.replace(COMMIT_ID_SUFFIX, '');
}
return sanitizeAssetBaseName(next, fallback);
}
export function isValidAssetCanvasName(name: string) {
if (
name.length === 0 ||
Array.from(name).length > 80 ||
Array.from(name).some(isForbiddenFileNameCharacter) ||
/[. ]$/u.test(name) ||
WINDOWS_RESERVED_NAMES.has(name.split('.')[0]?.toUpperCase() ?? '')
) {
return false;
}
return true;
}
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,5 @@
import type {
ImageCanvasDraft,
ImageCanvasGenerationCommitResult,
ImageCanvasGenerationPort,
ImageCanvasGenerationProgress,
ImageCanvasGenerationRecord,
@@ -8,6 +7,7 @@ import type {
ImageCanvasHostPort,
ImageCanvasHostResult,
ImageCanvasHostScope,
ImageCanvasLocalImportResult,
ImageCanvasMediaRef,
} from '@genarrative/image-canvas-core';
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
@@ -52,6 +52,12 @@ type StoredMediaResult = {
draft: ImageCanvasDraft | null;
};
type ImportedLocalImagesResult = {
status: 'imported' | 'cancelled' | 'conflict';
draft: ImageCanvasDraft | null;
importedLayerIds: string[];
};
type StagedImageResult = {
status: 'staged' | 'conflict';
stagedImageToken: string | null;
@@ -62,8 +68,20 @@ type StagedImageResult = {
type GenerationCommandResult = {
generation: ImageCanvasGenerationRecord;
images: [];
commit: ImageCanvasGenerationCommitResult;
draft: ImageCanvasDraft;
images: Array<{
mediaRef: ImageCanvasMediaRef;
resourceId: string;
}>;
};
type GenerationFailureCommandResult = {
disposition:
| 'failed'
| 'reconciliation-required'
| 'not-started'
| 'already-terminal';
draft: ImageCanvasDraft;
};
type GenerationProgressEvent = ImageCanvasGenerationProgress & {
@@ -173,20 +191,22 @@ function failure(error: unknown): ImageCanvasHostResult<never> {
const normalized = message.toLowerCase();
return {
status: 'failed',
code: message.includes('reconciliation-required')
? 'reconciliation-required'
: message.includes('project-identity-conflict')
? 'project-identity-conflict'
: normalized.includes('external editor api')
? 'platform-service-configuration'
: normalized.includes('authentication-required') ||
message.includes('登录已失效')
? 'authentication-required'
: message.includes('泥点余额不足')
? 'insufficient-mud-points'
: message.includes('平台图片生成服务暂不可用')
? 'platform-service-configuration'
: 'tauri-command-failed',
code: message.includes('素材名称无效')
? 'asset-name-invalid'
: message.includes('reconciliation-required')
? 'reconciliation-required'
: message.includes('project-identity-conflict')
? 'project-identity-conflict'
: normalized.includes('external editor api')
? 'platform-service-configuration'
: normalized.includes('authentication-required') ||
message.includes('登录已失效')
? 'authentication-required'
: message.includes('泥点余额不足')
? 'insufficient-mud-points'
: message.includes('平台图片生成服务暂不可用')
? 'platform-service-configuration'
: 'tauri-command-failed',
message,
};
}
@@ -228,6 +248,8 @@ export function createMockImageCanvasGenerationPort(): ImageCanvasGenerationPort
message: '当前阶段使用明确 mock,不会提交真实 AI 生成请求',
});
return {
settleGenerationFailure: unsupported,
archiveFailedGeneration: unsupported,
generateImage: unsupported,
recoverImages: async () => ({
status: 'ok' as const,
@@ -306,6 +328,53 @@ export function createTauriImageCanvasHostAdapter(input: {
);
};
const generation: ImageCanvasGenerationPort = {
async settleGenerationFailure(failureInput) {
try {
const result = await invokeInput<GenerationFailureCommandResult>(
'finalize_local_project_asset_canvas_generation_failure',
{
...baseScope(failureInput.scope),
expectedDraftRevision: safeRevision(
failureInput.expectedDraftRevision,
'expectedDraftRevision',
),
generationId: failureInput.generationId,
intentId: failureInput.intentId,
errorCode: failureInput.errorCode,
reconciliationRequired: failureInput.reconciliationRequired,
},
);
return { status: 'ok', value: result };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return message.includes('draft-revision-conflict')
? conflict('draft-revision', null, null)
: failure(error);
}
},
async archiveFailedGeneration(archiveInput) {
try {
const result = await invokeInput<{
generationId: string;
phase: 'archived';
archivedAt: number;
draft: ImageCanvasDraft;
}>('archive_failed_local_project_asset_canvas_generation', {
...baseScope(archiveInput.scope),
expectedDraftRevision: safeRevision(
archiveInput.expectedDraftRevision,
'expectedDraftRevision',
),
generationId: archiveInput.generationId,
});
return { status: 'ok', value: result.draft };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return message.includes('draft-revision-conflict')
? conflict('draft-revision', null, null)
: failure(error);
}
},
async generateImage(generationInput) {
let unlisten: (() => void) | undefined;
try {
@@ -337,9 +406,30 @@ export function createTauriImageCanvasHostAdapter(input: {
assetKind: generationInput.assetKind,
assetName: generationInput.assetName,
referenceResourceIds: generationInput.referenceResourceIds,
sourceLayerId: generationInput.sourceLayerId,
placeholder: generationInput.placeholder,
},
);
return { status: 'ok', value: result };
const images = await Promise.all(
result.images.map(async (image) => {
const preview = await invokeInput<{
mediaType: string;
bytes: number[];
}>('read_local_project_asset_canvas_media', {
...baseScope(generationInput.scope),
mediaRef: image.mediaRef,
});
return {
mediaRef: image.mediaRef,
resourceId: image.resourceId,
previewUrl: bytesToPreviewUrl(preview.bytes, preview.mediaType),
};
}),
);
return {
status: 'ok',
value: { generation: result.generation, images, draft: result.draft },
};
} catch (error) {
return failure(error);
} finally {
@@ -477,6 +567,44 @@ export function createTauriImageCanvasHostAdapter(input: {
},
},
asset: {
async importLocalImages(importInput) {
try {
const result = await invokeInput<ImportedLocalImagesResult>(
'import_local_project_asset_canvas_images',
{
...baseScope(importInput.scope),
expectedDraftRevision: safeRevision(
importInput.expectedDraftRevision,
'expectedDraftRevision',
),
viewportWidth: importInput.viewportSize.width,
viewportHeight: importInput.viewportSize.height,
},
);
if (result.status === 'conflict') {
return conflict('draft-revision', result.draft, null);
}
if (result.status === 'cancelled') {
return {
status: 'ok',
value: { status: 'cancelled' },
} satisfies ImageCanvasHostResult<ImageCanvasLocalImportResult>;
}
if (!result.draft) {
throw new Error('Tauri 导入完成后未返回权威草稿');
}
return {
status: 'ok',
value: {
status: 'imported',
draft: result.draft,
importedLayerIds: result.importedLayerIds,
},
} satisfies ImageCanvasHostResult<ImageCanvasLocalImportResult>;
} catch (error) {
return failure(error);
}
},
async importImages(importInput) {
const images = [];
try {
@@ -538,6 +666,78 @@ export function createTauriImageCanvasHostAdapter(input: {
}
},
completion: {
async commitSelectedCandidate(commitInput) {
try {
const expectedDraftRevision = safeRevision(
commitInput.expectedDraftRevision,
'expectedDraftRevision',
);
const expectedRevision = safeRevisionString(
commitInput.expectedHostRevision,
'expectedHostRevision',
);
const result = await invokeInput<LocalAssetCommitResult>(
'commit_local_project_asset_canvas_candidate',
{
...baseScope(commitInput.scope),
expectedRevision,
expectedDraftRevision,
commitId: commitInput.commitId,
idempotencyKey: commitInput.idempotencyKey,
sourceAssetId: commitInput.scope.sourceAssetId,
sourceLayerId: commitInput.sourceLayerId,
name: commitInput.name,
assetKind: commitInput.assetKind,
referenceResourceIds: commitInput.referenceResourceIds,
},
);
if (result.status === 'conflict') {
return conflict(
result.conflictKind === 'project-revision'
? 'host-revision'
: result.conflictKind,
null,
result.projectRevision,
);
}
if (
result.status === 'rolled-back' ||
result.status === 'reconciliation-required'
) {
return {
status: 'failed',
code: result.status,
message:
result.status === 'rolled-back'
? '最终图片替换已安全回滚'
: '最终图片替换需要恢复或人工对账',
};
}
if (
result.status !== 'committed' &&
result.status !== 'already-committed'
) {
return failure('最终图片替换返回未知状态');
}
return {
status: 'ok',
value: {
resourceId: result.asset.source.resourceId ?? result.asset.id,
assetId: result.asset.id,
projectId: result.projectId,
commitId: result.commitId,
committedProjectRevision: result.committedProjectRevision,
draftRevision: result.draftRevision,
hostRevision: String(result.projectRevision),
commitStatus: result.status,
manifest: result.manifest,
eventId: result.eventId,
},
};
} catch (error) {
return failure(error);
}
},
async commitImage(commitInput) {
try {
const expectedDraftRevision = safeRevision(
@@ -201,10 +201,10 @@ function gameChatRuntimeActivityTimes(runtime: AgentRuntimeState) {
function gameChatRuntimeIsExpectedWait(runtime: AgentRuntimeState) {
return Boolean(
runtime.pendingToolAction ||
runtime.userInputRequest ||
[runtime.status, runtime.phase, runtime.goalStatus].some((state) =>
GAME_CHAT_EXPECTED_WAIT_STATES.has(state ?? ''),
),
runtime.userInputRequest ||
[runtime.status, runtime.phase, runtime.goalStatus].some((state) =>
GAME_CHAT_EXPECTED_WAIT_STATES.has(state ?? ''),
),
);
}
@@ -984,11 +984,11 @@ export function SupervisorChatOnlyView({
!directCodex && Boolean(expectedRunId && runtime?.runId !== expectedRunId);
const running = Boolean(
chatAgentBusy ||
(!directCodex && synchronizingAcceptedRun) ||
(runtime &&
runtime.status !== 'needs-reconciliation' &&
runtime.phase !== 'needs-reconciliation' &&
!isAgentRuntimeTerminalState(runtime)),
(!directCodex && synchronizingAcceptedRun) ||
(runtime &&
runtime.status !== 'needs-reconciliation' &&
runtime.phase !== 'needs-reconciliation' &&
!isAgentRuntimeTerminalState(runtime)),
);
const gameChatInterruptionText =
!directCodex && gameChatMode && runtime
@@ -1113,18 +1113,18 @@ export function SupervisorChatOnlyView({
: null;
const expectedRuntimeWait = Boolean(
needsUserInput ||
pendingConfirmation ||
pendingCommand ||
(activeRuntimeLanes.length > 0 &&
activeRuntimeLanes.every(gameChatRuntimeIsExpectedWait)),
pendingConfirmation ||
pendingCommand ||
(activeRuntimeLanes.length > 0 &&
activeRuntimeLanes.every(gameChatRuntimeIsExpectedWait)),
);
const runtimeAppearsStalled = Boolean(
running &&
runtime &&
!runtimeTerminal &&
!expectedRuntimeWait &&
inactiveRuntimeMs !== null &&
inactiveRuntimeMs > GAME_CHAT_RUNTIME_STALL_THRESHOLD_MS,
runtime &&
!runtimeTerminal &&
!expectedRuntimeWait &&
inactiveRuntimeMs !== null &&
inactiveRuntimeMs > GAME_CHAT_RUNTIME_STALL_THRESHOLD_MS,
);
const runStateLabel = (() => {
if (gameChatInterruptionText) {
+112 -2
View File
@@ -5028,6 +5028,10 @@ iframe.preview-frame {
min-height: 0;
}
.game-workbench-layout.is-ui-editor {
grid-template-columns: minmax(0, 1fr);
}
.game-workbench-stage,
.game-workbench-chat {
min-width: 0;
@@ -5055,10 +5059,17 @@ iframe.preview-frame {
box-shadow: none;
}
.game-workbench-stage[data-resource-view-state^='resources.asset-canvas'] {
.game-workbench-stage[data-resource-view-state^='resources.asset-canvas'],
.game-workbench-stage[data-resource-view-state='resources.ui-editor'] {
grid-template-rows: auto minmax(0, 1fr);
}
.game-workbench-stage[data-resource-view-state='resources.ui-editor']
> div:last-child {
grid-row: 2;
min-height: 0;
}
.game-workbench-stage[data-resource-view-state^='resources.asset-canvas']
> .asset-canvas-surface {
grid-row: 2;
@@ -5276,6 +5287,18 @@ iframe.preview-frame {
background-color: #fffdfa;
background-image: radial-gradient(#eaded8 0.8px, transparent 0.8px);
background-size: 18px 18px;
user-select: none;
}
.game-resource-canvas--dependency {
overflow: hidden;
cursor: grab;
overscroll-behavior: none;
touch-action: none;
}
.game-resource-canvas--dependency:active {
cursor: grabbing;
}
.game-resource-canvas-content {
@@ -5287,6 +5310,12 @@ iframe.preview-frame {
min-height: 100%;
}
.game-resource-canvas--dependency .game-resource-canvas-content {
display: block;
min-width: 0;
min-height: 0;
}
.game-resource-section-stack {
display: contents;
}
@@ -5313,6 +5342,35 @@ iframe.preview-frame {
border: 0;
}
.game-resource-world-section-heading {
position: absolute;
right: 24px;
left: 24px;
z-index: 1;
display: flex;
align-items: center;
justify-content: space-between;
min-height: 32px;
padding: 0 8px;
border-bottom: 1px solid #eaded8;
color: #725348;
font-size: 12px;
font-weight: 800;
pointer-events: none;
}
.game-resource-world-section-heading > span {
display: inline-flex;
align-items: center;
gap: 6px;
}
.game-resource-world-section-heading small {
color: #9b6b56;
font-size: 10px;
font-weight: 700;
}
.game-resource-dependency-edge,
.game-resource-dependency-edge path {
fill: none;
@@ -5445,6 +5503,29 @@ iframe.preview-frame {
scrollbar-gutter: stable;
}
.game-resource-canvas--dependency .game-resource-section {
overflow: visible;
}
.game-resource-canvas--dependency .game-resource-section-viewport {
overflow: visible;
scrollbar-gutter: auto;
}
.game-resource-canvas--dependency .game-resource-section-height-actions,
.game-resource-canvas--dependency .game-resource-section-zoom-actions {
display: none;
}
.game-resource-canvas--dependency .game-resource-section {
pointer-events: none;
}
.game-resource-canvas--dependency .game-resource-card,
.game-resource-canvas--dependency .game-resource-section > header {
pointer-events: auto;
}
.game-resource-section-viewport:focus-visible {
outline: 2px solid rgb(206 118 80 / 26%);
outline-offset: -2px;
@@ -5658,15 +5739,44 @@ iframe.preview-frame {
}
.game-resource-focus {
position: absolute;
top: 50%;
left: 50%;
z-index: 100;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
width: 100%;
min-width: 0;
min-height: 0;
width: min(560px, calc(100% - 32px));
max-height: calc(100% - 32px);
border: 1px solid #ead8cf;
border-radius: 16px;
box-shadow: 0 18px 52px rgb(73 42 29 / 20%);
overflow: hidden;
background: #fffdfa;
color: #563b31;
outline: 0;
transform: translate(-50%, -50%);
}
.game-resource-image-preview {
display: grid;
min-height: 160px;
max-height: 280px;
padding: 12px;
border: 1px solid #ead8cf;
border-radius: 8px;
background: #f8f0eb;
place-items: center;
}
.game-resource-image-preview img {
display: block;
width: auto;
max-width: 100%;
height: auto;
max-height: 252px;
object-fit: contain;
}
.game-resource-focus-titlebar {
@@ -13,8 +13,8 @@ import type {
ProjectResourceCanvasSection,
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
import {
RESOURCE_CANVAS_CARD_HEIGHT,
RESOURCE_CANVAS_CARD_WIDTH,
resourceCanvasCardSize,
type ResourceCanvasCardSizeByResourceId,
} from './resourceCanvasLayoutModel';
import {
type ProjectResourceGraph,
@@ -38,8 +38,9 @@ type RectLookup = {
export type ResourceDependencyOverlayProps = {
graph: ProjectResourceGraph;
positions: readonly ProjectResourceCanvasPosition[];
section: ProjectResourceCanvasSection;
section?: ProjectResourceCanvasSection;
visibleResourceIds: ReadonlySet<string>;
cardSizeByResourceId?: ResourceCanvasCardSizeByResourceId;
geometryRevision?: string;
};
@@ -458,7 +459,14 @@ export const ResourceDependencyOverlay = forwardRef<
ResourceDependencyOverlayHandle,
ResourceDependencyOverlayProps
>(function ResourceDependencyOverlay(
{ geometryRevision = '', graph, positions, section, visibleResourceIds },
{
cardSizeByResourceId,
geometryRevision = '',
graph,
positions,
section,
visibleResourceIds,
},
ref,
) {
const markerPrefix = useId().replace(/[^a-zA-Z0-9_-]/gu, '');
@@ -484,6 +492,10 @@ export const ResourceDependencyOverlay = forwardRef<
useLayoutEffect(() => {
const plane = overlayRef.current?.parentElement;
const viewport = plane?.closest<HTMLElement>(SECTION_SCROLL_SELECTOR);
if (plane?.closest('.game-resource-canvas--dependency')) {
setLogicalViewport(null);
return undefined;
}
if (!plane || !viewport) {
setLogicalViewport(null);
return undefined;
@@ -533,18 +545,22 @@ export const ResourceDependencyOverlay = forwardRef<
viewport.removeEventListener('scroll', scheduleMeasure);
window.removeEventListener('resize', scheduleMeasure);
};
}, [geometryRevision, section]);
}, [geometryRevision]);
const rectByResourceId = useMemo(() => {
const result = new Map<string, Rect>();
for (const position of positions) {
if (
position.section !== section ||
(section !== undefined && position.section !== section) ||
!graph.resourceIds.has(position.resourceId) ||
!visibleResourceIds.has(position.resourceId)
) {
continue;
}
const cardSize = resourceCanvasCardSize(
position.resourceId,
cardSizeByResourceId,
);
result.set(position.resourceId, {
x:
dragPreview?.resourceId === position.resourceId
@@ -554,12 +570,19 @@ export const ResourceDependencyOverlay = forwardRef<
dragPreview?.resourceId === position.resourceId
? dragPreview.y
: position.y,
width: RESOURCE_CANVAS_CARD_WIDTH,
height: RESOURCE_CANVAS_CARD_HEIGHT,
width: cardSize.width,
height: cardSize.height,
});
}
return result;
}, [dragPreview, graph.resourceIds, positions, section, visibleResourceIds]);
}, [
cardSizeByResourceId,
dragPreview,
graph.resourceIds,
positions,
section,
visibleResourceIds,
]);
const sectionByResourceId = useMemo(
() =>
new Map(
@@ -571,8 +594,9 @@ export const ResourceDependencyOverlay = forwardRef<
() =>
graph.referenceEdges.filter(
(edge) =>
sectionByResourceId.get(edge.sourceResourceId) === section &&
referenceStaysInSection(edge, sectionByResourceId),
section === undefined ||
(sectionByResourceId.get(edge.sourceResourceId) === section &&
referenceStaysInSection(edge, sectionByResourceId)),
),
[graph.referenceEdges, section, sectionByResourceId],
);
@@ -651,8 +675,12 @@ export const ResourceDependencyOverlay = forwardRef<
<svg
ref={overlayRef}
className="game-resource-dependency-overlay"
data-testid={`resource-dependency-overlay-${section}`}
data-resource-section={section}
data-testid={
section
? `resource-dependency-overlay-${section}`
: 'resource-dependency-overlay-world'
}
{...(section ? { 'data-resource-section': section } : {})}
data-logical-viewport={
logicalViewport
? `${logicalViewport.x},${logicalViewport.y},${logicalViewport.width},${logicalViewport.height}`
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -18,10 +18,17 @@ export type ProjectResourceCardPreviewKind =
| 'version'
| 'placeholder';
export type ProjectResourceImageDimensions = {
pixelWidth: number;
pixelHeight: number;
};
export type ProjectResourceCardPreviewPayload = {
path: string;
mediaType: string;
byteLen: number;
pixelWidth?: number;
pixelHeight?: number;
sourceUrl?: string;
content?: string;
};
@@ -33,6 +40,29 @@ export type ProjectResourceCardPreviewTransportPayload = Omit<
dataUrl?: string;
};
export function projectResourceCardPreviewImageDimensions(
preview: Pick<
ProjectResourceCardPreviewPayload,
'pixelWidth' | 'pixelHeight'
>,
): ProjectResourceImageDimensions | null {
const { pixelWidth, pixelHeight } = preview;
if (pixelWidth === undefined && pixelHeight === undefined) {
return null;
}
if (
typeof pixelWidth !== 'number' ||
typeof pixelHeight !== 'number' ||
!Number.isSafeInteger(pixelWidth) ||
!Number.isSafeInteger(pixelHeight) ||
pixelWidth <= 0 ||
pixelHeight <= 0
) {
throw new Error('图片预览像素尺寸无效');
}
return { pixelWidth, pixelHeight };
}
export type ProjectResourceCardPreviewCacheEntry = {
identity: string;
retainedBytes: number;
@@ -74,6 +74,8 @@ export function createResourceSignature(resources: ResourceCanvasItem[]) {
resource.label,
resource.dependencyDepth,
resource.mediaType,
resource.cardSize?.width ?? null,
resource.cardSize?.height ?? null,
]),
)
.sort()

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