合并资源规范与画布 JSON 功能
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 7m2s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 7m4s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 7m20s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 7m21s
Project CI / Backend tests (pull_request) Failing after 14s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m52s
Project CI / Repository checks (pull_request) Failing after 11s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 3m5s
Project CI / Frontend tests (pull_request) Failing after 5m26s
Project CI / AI game creator shell web tests (pull_request) Failing after 4m11s
Project CI / Native shell tests (pull_request) Successful in 10m40s

合并 canonical 资源 kind 校验与按类型提示词上限。\n保留 UI JSON 预览识别、严格 UI 文档持久化和画布编辑入口。\n修复合并后 Rust 资源 kind 测试夹具并补齐定向回归用例。
This commit is contained in:
2026-09-17 18:28:19 +08:00
48 changed files with 3107 additions and 150 deletions
@@ -15,7 +15,7 @@
"autoCompactTokenLimit": 64000,
"toolOutputTokenLimit": 12000,
"requestTimeoutMs": 180000,
"maxRetries": 2,
"maxRetries": 10,
"retryBackoffMs": 500
},
"agentLlm": {}
@@ -13,7 +13,7 @@ Let the client derive projections from real disk changes and trusted tool result
2. Before using or deriving an existing registered asset, call `agc_list_registered_assets` and select its `localAssetId`. If the user points to an existing project file that is not listed, first call `agc_list_project_files`; only entries with `assetImportable=true` (recognized image, font, audio, video, document, or code files) may be passed to `agc_import_account_assets.localPaths`. Then re-read `agc_list_registered_assets`; never infer a source identity from a filename or fabricate a localAssetId.
3. Keep read scopes separate: `asset.list` is the current project manifest, `asset.library.list` is the signed-in account library, and the web project's canvas resource read model is the authoritative canvas list. The account library is not the complete canvas list.
4. Use `canvas.asset_import` for safe account/canvas asset IDs or project-relative local paths. The client rechecks ownership and validates bytes; host absolute paths require native UI file-picker authorization.
5. When the user explicitly asks to create or derive video, character animation, sound effect, or background music, call `agc_create_or_derive_resource`. Use `create` only for video/audio without a source and `derive` with a registered `sourceLocalAssetId`; character animation is always derived from an image.
5. When the user explicitly asks to create or derive video, character animation, sound effect, or background music, call `agc_create_or_derive_resource`. Use `create` only for video/audio without a source and `derive` with a registered `sourceLocalAssetId`; character animation is always derived from an image. Keep `prompt` inside the per-kind limit that the client really enforces: background music at most 140 characters, sound effect at most 1900, video and character animation at most 4000. A longer prompt is rejected before submission, so write the short version first instead of retrying the same text.
6. When the user explicitly asks to remove an image background, call `agc_remove_background` with a registered image `sourceLocalAssetId` and `assetName`. Optional `backgroundMode` is `complex` (semantic foreground segmentation; default) or `flat` (solid-colour background removal). Prefer `flat` when the background is known to be solid. Only `flat` accepts optional `screenColor`: `auto`, `#RRGGBB`, or omitted for automatic detection by the service. Do not select a colour on behalf of `auto`. The client requires the signed-in account, owns canvas/folder context and task identity, and returns only bounded queue state.
7. Preserve existing relative paths when a small edit is sufficient so client resource identities remain stable.
8. Do not edit `.agent/manifest.json`, revision counters, version records, resource IDs, canvas identities, source provenance, generation ledgers, or browser receipts by hand.
@@ -14,4 +14,6 @@ Read scopes remain separate: `asset.list` is the current project's local manifes
`agc_create_or_derive_resource` accepts only semantic intent. The client resolves `sourceLocalAssetId`, creates stable request identities, recovers matching pending operations, serializes paid submissions, writes supported media into the current canvas and same-name asset folder, validates downloaded bytes, commits the local manifest transaction, and returns redacted warnings. A tool error or timeout is not permission to generate again with a new identity.
`prompt` limits are per kind and are enforced before any paid submission: background music accepts 1-140 characters, sound effect 1-1900, video and character animation 1-4000, and image editing (`agc_edit_image`) 1-32000. The client composes the submitted request from a fixed prefix plus your prompt, so an over-limit prompt fails locally with the exact limit; shorten the text rather than resubmitting the same value. `agc_edit_image` remains the image path; this tool never generates or edits still images.
`agc_remove_background` accepts a registered image `sourceLocalAssetId`, `assetName`, and optional `backgroundMode` and `screenColor`. `complex` uses semantic segmentation to identify the foreground; `flat` removes a solid-colour background. Prefer `flat` when the background is known to be solid; omitting the mode selects `complex`. Only `flat` accepts a colour: `auto`, `#RRGGBB`, or omitted for automatic service detection. Never infer a concrete colour for `auto`. Empty or invalid values and colour without `flat` are rejected. The client resolves the formal source resource, canvas/folder context, stable operation identity, idempotency key, and authenticated External v1 `/api/external/v1/editor/images/background-removals` call. Mode and colour are part of request identity. Its result is bounded queue state; Codex must not poll internal workers, construct source URLs, or retry with a new identity after an uncertain response.
@@ -1,6 +1,6 @@
{
"schemaVersion": "agc-skill-pack.v1",
"version": "2026-08-26.17",
"version": "2026-08-26.18",
"skills": [
{
"name": "agc-game-production-workflow",
@@ -123,7 +123,7 @@
"agents/openai.yaml",
"references/projection-contract.md"
],
"sha256": "a929c27bc5b2b0bee0b7935e5c7b04ddbab1eb1804fe196f8c2537ad040ca5b1"
"sha256": "0700d4a7a18ee6151811f38786211ad416863f2e425fdc2ded67555a0a1923a1"
}
]
}
@@ -380,11 +380,16 @@ pub(crate) fn execute_design_file_tool(
}
return Err(details.join("\n"));
}
let mut updated = content.clone();
for (index, start, end) in matches.into_iter().rev() {
matches.sort_unstable_by_key(|(_, start, _)| *start);
let mut updated = String::with_capacity(content.len());
let mut cursor = 0;
for (index, start, end) in matches {
let (_, new) = &normalized[index];
updated.replace_range(start..end, new);
updated.push_str(&content[cursor..start]);
updated.push_str(new);
cursor = end;
}
updated.push_str(&content[cursor..]);
if updated == content {
return Err(format!("没有产生修改:{display}"));
}
@@ -834,6 +839,35 @@ mod tests {
assert!(!root.join("design_artifacts/notes").exists());
}
#[test]
fn patch_file_applies_out_of_order_edits_with_changing_utf8_lengths() {
let temp = test_root();
let root = temp.path();
execute_design_file_tool(
root,
"write_file",
&json!({"path":"notes/design.md","content":"开头\n\n保留一\n乙乙\n保留二\n\n结尾"}),
)
.expect("write");
execute_design_file_tool(
root,
"patch_file",
&json!({
"path":"notes/design.md",
"edits":[
{"old_text":"","new_text":"新的结论"},
{"old_text":"","new_text":"扩展A"},
{"old_text":"乙乙","new_text":""}
]
}),
)
.expect("patch out of order");
assert_eq!(
fs::read_to_string(root.join("design_artifacts/notes/design.md")).expect("read disk"),
"开头\n扩展A\n保留一\n\n保留二\n新的结论\n结尾"
);
}
#[test]
fn phase_context_injects_current_skill_only() {
let resources = DesignResources::new(pack_root()).expect("pack");
@@ -482,9 +482,38 @@ pub(crate) fn read_direct_project_history_items_at(root: &Path) -> Result<Vec<Va
}
fn read_direct_project_history_entries_at(root: &Path) -> Result<Vec<(Value, u64)>, String> {
read_direct_project_history_entries_filtered_at(root, None, false)
}
fn is_direct_project_chat_message(item: &Value) -> bool {
matches!(
item.get("role").and_then(Value::as_str),
Some("user" | "assistant")
) && item
.get("content")
.and_then(Value::as_array)
.is_some_and(|parts| {
parts.iter().any(|part| {
part.get("text")
.and_then(Value::as_str)
.is_some_and(|text| !text.is_empty())
})
})
}
/// 消息模式逐行丢弃工具输出,只保留聊天正文,避免 40 MiB 工具日志被整表积累或发给 UI。
fn read_direct_project_history_entries_filtered_at(
root: &Path,
before_item_id: Option<&str>,
messages_only: bool,
) -> Result<Vec<(Value, u64)>, String> {
let path = history_path(root);
if !prepare_game_creator_private_path_for_read(&path, false, "DirectProject 历史")? {
return Ok(Vec::new());
return if before_item_id.is_some() {
Err("DirectProject 历史游标对应的文件已不存在".to_string())
} else {
Ok(Vec::new())
};
}
let file = File::open(&path)
.map_err(|error| format!("打开 DirectProject 历史失败:{}: {error}", path.display()))?;
@@ -519,6 +548,12 @@ fn read_direct_project_history_entries_at(root: &Path) -> Result<Vec<(Value, u64
if is_direct_project_internal_context_item(&item) {
continue;
}
if before_item_id.is_some_and(|id| item.get("id").and_then(Value::as_str) == Some(id)) {
return Ok(items);
}
if messages_only && !is_direct_project_chat_message(&item) {
continue;
}
items.push((
item,
parsed
@@ -527,7 +562,45 @@ fn read_direct_project_history_entries_at(root: &Path) -> Result<Vec<(Value, u64
.unwrap_or(0),
));
}
Ok(items)
match before_item_id {
Some(item_id) => Err(format!("DirectProject 历史中不存在 item{item_id}")),
None => Ok(items),
}
}
pub(crate) fn read_direct_project_chat_items_slice_at(
root: &Path,
before_item_id: Option<&str>,
limit: usize,
) -> Result<(Vec<Value>, bool, BTreeMap<String, u64>), String> {
let entries = read_direct_project_history_entries_filtered_at(root, before_item_id, true)?;
let mut start = entries.len().saturating_sub(limit.clamp(1, 200));
// 旧消息可能没有 ID:保留原文,并向前扩到可寻址的已有 ID,不能制造原始消息身份。
while start > 0
&& entries[start]
.0
.get("id")
.and_then(Value::as_str)
.is_none_or(str::is_empty)
{
start -= 1;
}
let timestamps = entries[start..]
.iter()
.filter_map(|(item, at)| {
let id = item.get("id").and_then(Value::as_str)?;
(*at > 0).then(|| (id.to_string(), *at))
})
.collect();
Ok((
entries
.into_iter()
.skip(start)
.map(|(item, _)| item)
.collect(),
start > 0,
timestamps,
))
}
pub(crate) fn read_direct_project_history_items_slice_at(
@@ -640,6 +713,165 @@ mod tests {
const RESPONSE_ITEM_ROW: &str = r#"{"type":"response_item","payload":{"type":"message","role":"user","id":"codex-item-2","content":[{"type":"input_text","text":"再加一个按钮"}]}}"#;
const RESPONSE_ASSISTANT_ROW: &str = r#"{"type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"已完成"}]}}"#;
fn write_items(root: &std::path::Path, items: &[Value]) {
let lines = items
.iter()
.enumerate()
.map(|(index, item)| {
json!({"type": "response_item", "payload": item, "recordedAt": 1000 + index})
.to_string()
})
.collect::<Vec<_>>();
write_history_lines(root, &lines.iter().map(String::as_str).collect::<Vec<_>>());
}
#[test]
fn chat_pages_skip_tool_only_tail_and_gaps_without_losing_messages_or_times() {
let root = init_history_project("message-pages");
let mut raw = Vec::new();
let mut expected = Vec::new();
for n in 0..44 {
let item = json!({
"id": format!("message-{n}"), "type": "message",
"role": if n == 0 || n == 38 { "user" } else { "assistant" },
"content": [{"type": "output_text", "text": format!("消息 {n}")}],
});
expected.push(item.clone());
raw.push(item);
for tool in 0..25 {
raw.push(json!({
"id": format!("tool-{n}-{tool}"), "type": "function_call_output",
"output": "工具结果不应占聊天页名额",
}));
}
}
write_items(root.path(), &raw);
let path = history_path(root.path());
let before = std::fs::read(&path).unwrap();
let (old_page, _, _) =
super::read_direct_project_history_items_slice_at(root.path(), None, 20).unwrap();
assert!(old_page
.iter()
.all(|item| item["type"] == "function_call_output"));
let mut cursor = None;
let mut all = Vec::new();
let mut sizes = Vec::new();
loop {
let (mut page, more, timestamps) =
super::read_direct_project_chat_items_slice_at(root.path(), cursor.as_deref(), 20)
.unwrap();
sizes.push(page.len());
for item in &page {
let index = raw.iter().position(|raw| raw["id"] == item["id"]).unwrap();
assert_eq!(
timestamps[item["id"].as_str().unwrap()],
1000 + index as u64
);
}
let next = page
.first()
.and_then(|item| item["id"].as_str())
.map(str::to_string);
page.append(&mut all);
all = page;
if !more {
break;
}
assert_ne!(next, cursor);
cursor = next;
assert!(sizes.len() < 10);
}
assert_eq!(sizes, vec![20, 20, 4]);
assert_eq!(all, expected);
assert_eq!(std::fs::read(&path).unwrap(), before);
}
#[test]
fn chat_pages_handle_empty_content_internal_context_and_missing_ids() {
let root = init_history_project("message-page-boundary");
write_items(
root.path(),
&[
json!({"id":"u", "role":"user", "content":[{"text":"第一条"}]}),
json!({"role":"assistant", "content":[{"text":"无ID的旧消息"}]}),
json!({"id":"a", "role":"assistant", "content":[{"text":"最后一条"}]}),
json!({"id":"empty", "role":"assistant", "content":[{"text":""}]}),
json!({"id":"internal", "role":"user", "content":[{"text":"<environment_context>内部</environment_context>"}]}),
json!({"id":"reason", "type":"reasoning", "content":[{"text":"推理"}]}),
],
);
let (page, more, _) =
super::read_direct_project_chat_items_slice_at(root.path(), None, 1).unwrap();
assert_eq!(page[0]["id"], "a");
assert!(more);
let (page, more, _) =
super::read_direct_project_chat_items_slice_at(root.path(), Some("a"), 1).unwrap();
assert_eq!(page.len(), 2);
assert_eq!(page[0]["id"], "u");
assert!(page[1].get("id").is_none());
assert!(!more);
assert!(
super::read_direct_project_chat_items_slice_at(root.path(), Some("missing"), 20)
.is_err()
);
write_items(
root.path(),
&[json!({"id":"tool", "type":"function_call", "arguments":"{}"})],
);
let (page, more, _) =
super::read_direct_project_chat_items_slice_at(root.path(), None, 20).unwrap();
assert!(page.is_empty());
assert!(!more);
}
#[test]
#[ignore = "人工只读诊断:通过 AGC_HISTORY_REPLAY_SOURCE 提供原始历史文件"]
fn replay_external_chat_history_pages_without_mutating_source() {
let source = std::env::var_os("AGC_HISTORY_REPLAY_SOURCE").expect("provide replay source");
let before = std::fs::read(&source).expect("read source");
let root = init_history_project("external-history-replay");
let path = history_path(root.path());
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, &before).unwrap();
let expected =
super::read_direct_project_history_entries_filtered_at(root.path(), None, true)
.expect("read messages");
let mut cursor = None;
let mut all = Vec::new();
let mut pages = 0;
loop {
let (mut items, more, _) =
super::read_direct_project_chat_items_slice_at(root.path(), cursor.as_deref(), 20)
.expect("read page");
let next = items
.first()
.and_then(|item| item["id"].as_str())
.map(str::to_string);
items.append(&mut all);
all = items;
pages += 1;
if !more {
break;
}
assert!(next.is_some() && next != cursor, "cursor must advance");
assert!(pages <= expected.len() + 1, "pagination must terminate");
cursor = next;
}
assert!(
all.iter().eq(expected.iter().map(|(item, _)| item)),
"message order and content must match"
);
assert!(
std::fs::read(&source).unwrap() == before,
"source must remain unchanged"
);
eprintln!(
"history replay: messages={}, pages={pages}, users={}",
all.len(),
all.iter().filter(|item| item["role"] == "user").count()
);
}
#[test]
fn history_timestamps_survive_reload_and_idempotent_append_without_changing_raw_items() {
let root = init_history_project("history-time");
@@ -59,6 +59,7 @@ pub(crate) struct DirectThreadHistorySlice {
pub(crate) items: Vec<Value>,
pub(crate) has_more: bool,
pub(crate) item_timestamps: std::collections::BTreeMap<String, u64>,
pub(crate) oldest_item_id: Option<String>,
}
#[derive(Clone, Debug)]
@@ -24,7 +24,6 @@ const DIRECT_TOOL_BRIDGE_IMAGE_PREVIEW_MAX_DIMENSION: u32 = 1024;
const DIRECT_TOOL_BRIDGE_MAX_SEARCH_QUERY_CHARS: usize = 400;
const DIRECT_TOOL_BRIDGE_MAX_SEARCH_RESULTS: usize = 5;
const DIRECT_TOOL_BRIDGE_SEARCH_URL: &str = "https://www.bing.com/search?format=rss";
const DIRECT_TOOL_BRIDGE_MAX_RESOURCE_PROMPT_CHARS: usize = 4_000;
const DIRECT_TOOL_BRIDGE_MAX_RESOURCE_NAME_CHARS: usize = 120;
const DIRECT_TOOL_BRIDGE_MAX_RESOURCE_KIND_CHARS: usize = 80;
const DIRECT_TOOL_BRIDGE_MAX_RESOURCE_PAGE_SIZE: usize = 100;
@@ -89,7 +88,7 @@ struct DirectToolBridgeRequest {
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum DirectResourceGenerationKind {
pub(crate) enum DirectResourceGenerationKind {
Image,
Video,
CharacterAnimation,
@@ -98,7 +97,7 @@ enum DirectResourceGenerationKind {
}
impl DirectResourceGenerationKind {
fn parse(value: &str) -> Result<Self, String> {
pub(crate) fn parse(value: &str) -> Result<Self, String> {
match value {
"image" => Ok(Self::Image),
"video" => Ok(Self::Video),
@@ -119,7 +118,7 @@ impl DirectResourceGenerationKind {
}
}
fn edit_kind(self) -> LocalProjectResourceEditKind {
pub(crate) fn edit_kind(self) -> LocalProjectResourceEditKind {
match self {
Self::Image => LocalProjectResourceEditKind::ImageReference,
Self::Video => LocalProjectResourceEditKind::Video,
@@ -128,6 +127,11 @@ impl DirectResourceGenerationKind {
Self::BackgroundMusic => LocalProjectResourceEditKind::BackgroundMusic,
}
}
/// 提示词上限只从客户端权威口径取值,工具桥与 MCP 层共用同一份数字。
pub(crate) fn prompt_max_chars(self) -> usize {
resource_edit_prompt_max_chars(&self.edit_kind())
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -1036,6 +1040,12 @@ fn bridge_account_asset_import_inputs(
Ok((asset_ids, local_paths))
}
/// 源资源身份不在当前项目 manifest 时的统一提示。
///
/// 只报「不属于已登记资源」会让模型原地重试;这里必须把下一步可执行动作写清楚:
/// 已登记资源走 `agc_list_registered_assets`,只在项目里存在的文件先登记再重试。
const DIRECT_TOOL_BRIDGE_UNREGISTERED_SOURCE_MESSAGE: &str = "sourceLocalAssetId 不是当前项目已登记资源:先调用 agc_list_registered_assets 选择已有 localAssetId;若目标图片只在项目里,先用 agc_list_project_files 确认它 assetImportable=true,再用 agc_import_account_assets.localPaths 登记后重试。";
fn bridge_resource_generation_input(
arguments: &Value,
) -> Result<DirectResourceGenerationInput, String> {
@@ -1054,18 +1064,20 @@ fn bridge_resource_generation_input(
"sourceLocalAssetId",
DIRECT_TOOL_BRIDGE_MAX_RESOURCE_KIND_CHARS,
)?;
let prompt = bridge_bounded_string(
arguments,
"prompt",
DIRECT_TOOL_BRIDGE_MAX_RESOURCE_PROMPT_CHARS,
)?;
// prompt 的形状校验只用信封级上限,真正生效的按 kind 上限由紧随其后的权威判定给出
// 精确数字;否则通用 4000 会先于「图片编辑 32000 / 音效 1900」误报成安全边界错误。
let prompt = bridge_bounded_string(arguments, "prompt", DIRECT_TOOL_BRIDGE_MAX_REQUEST_BYTES)?;
let asset_name = bridge_bounded_string(
arguments,
"assetName",
DIRECT_TOOL_BRIDGE_MAX_RESOURCE_NAME_CHARS,
)?;
if kind == DirectResourceGenerationKind::BackgroundMusic && prompt.chars().count() > 140 {
return Err("背景音乐提示词必须在 1..=140 字符内".to_string());
let prompt_max_chars = kind.prompt_max_chars();
if prompt.chars().count() > prompt_max_chars {
return Err(resource_edit_prompt_limit_error(
&kind.edit_kind(),
prompt_max_chars,
));
}
match (kind, mode, source_local_asset_id.as_ref()) {
(DirectResourceGenerationKind::Image, DirectResourceGenerationMode::Create, _) => {
@@ -1826,7 +1838,7 @@ async fn bridge_create_or_derive_resource(
.iter()
.find(|asset| asset.id == asset_id)
.cloned()
.ok_or_else(|| "sourceLocalAssetId 不属于当前项目已登记资源".to_string())
.ok_or_else(|| DIRECT_TOOL_BRIDGE_UNREGISTERED_SOURCE_MESSAGE.to_string())
})
.transpose()?;
let prompt_sha256 = format!("{:x}", Sha256::digest(input.prompt.as_bytes()));
@@ -1923,7 +1935,7 @@ async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Val
.assets
.iter()
.find(|asset| asset.id == source_asset_id)
.ok_or_else(|| "sourceLocalAssetId 不属于当前项目已登记资源".to_string())?;
.ok_or_else(|| DIRECT_TOOL_BRIDGE_UNREGISTERED_SOURCE_MESSAGE.to_string())?;
if !source_asset.media_type.starts_with("image/") {
return Err("抠图工具只接受当前项目已登记的图片资源".to_string());
}
@@ -2837,6 +2849,67 @@ mod tests {
);
}
/// 按 kind 的提示词上限只来自客户端权威口径;超限必须在构造工具输入时就被拒绝,
/// 不能再出现写死的数字(2026-09-17 的背景音乐 140 就是写死在桥这一层的)。
#[test]
fn bridge_resource_prompt_limits_follow_the_client_authority() {
for (kind, edit_kind) in [
(
"background-music",
LocalProjectResourceEditKind::BackgroundMusic,
),
("sound-effect", LocalProjectResourceEditKind::SoundEffect),
("video", LocalProjectResourceEditKind::Video),
(
"character-animation",
LocalProjectResourceEditKind::CharacterAnimation,
),
("image", LocalProjectResourceEditKind::ImageReference),
] {
let authority = resource_edit_prompt_max_chars(&edit_kind);
let mode = if matches!(
edit_kind,
LocalProjectResourceEditKind::ImageReference
| LocalProjectResourceEditKind::CharacterAnimation
) {
"derive"
} else {
"create"
};
let mut arguments = json!({
"kind": kind,
"mode": mode,
"prompt": "".repeat(authority),
"assetName": "边界名称"
});
if mode == "derive" {
arguments["sourceLocalAssetId"] = json!("registered-source");
}
bridge_resource_generation_input(&arguments)
.unwrap_or_else(|error| panic!("{kind} 恰好等于上限必须通过:{error}"));
arguments["prompt"] = json!("".repeat(authority + 1));
let error = match bridge_resource_generation_input(&arguments) {
Ok(_) => panic!("{kind} 超过按 kind 上限的提示词必须被拒绝"),
Err(error) => error,
};
assert!(
error.contains(&authority.to_string()) && error.contains(kind_label(&edit_kind)),
"{kind} 的拒绝文案必须带上真实上限与类型:{error}"
);
}
}
fn kind_label(edit_kind: &LocalProjectResourceEditKind) -> &'static str {
match edit_kind {
LocalProjectResourceEditKind::BackgroundMusic => "背景音乐",
LocalProjectResourceEditKind::SoundEffect => "音效",
LocalProjectResourceEditKind::Video => "视频",
LocalProjectResourceEditKind::CharacterAnimation => "角色动画",
_ => "资源编辑",
}
}
#[test]
fn bridge_argument_bounds_are_deterministic() {
assert_eq!(
File diff suppressed because it is too large Load Diff
@@ -5057,7 +5057,21 @@ pub(crate) fn read_local_project_text_preview_at(
return Err("只能读取当前项目已登记的文档资源".to_string());
}
cancellation.check()?;
load_local_project_text_preview_with_cancellation(root, &normalized_path, cancellation)
let mut preview =
load_local_project_text_preview_with_cancellation(root, &normalized_path, cancellation)?;
if normalized_path.to_ascii_lowercase().ends_with(".json") {
preview.ui_design_asset_id = manifest.assets.iter().find_map(|asset| {
(asset.local_path == normalized_path
&& ui_editor::persistence::is_valid_ui_design_json(
&preview.content,
&manifest.project_id,
&asset.id,
))
.then(|| asset.id.clone())
});
}
cancellation.check()?;
Ok(preview)
}
#[tauri::command]
@@ -5423,19 +5437,29 @@ pub(crate) async fn read_direct_project_history_slice(
project_path: String,
before_item_id: Option<String>,
limit: Option<usize>,
messages_only: Option<bool>,
) -> Result<DirectThreadHistorySlice, String> {
tauri::async_runtime::spawn_blocking(move || {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "conversation.read")?;
let (items, has_more, item_timestamps) = read_direct_project_history_items_slice_at(
root,
before_item_id.as_deref(),
limit.unwrap_or(20),
)?;
let read_slice = if messages_only.unwrap_or(false) {
read_direct_project_chat_items_slice_at
} else {
read_direct_project_history_items_slice_at
};
let (items, has_more, item_timestamps) =
read_slice(root, before_item_id.as_deref(), limit.unwrap_or(20))?;
let oldest_item_id = items
.first()
.and_then(|item| item.get("id"))
.and_then(serde_json::Value::as_str)
.filter(|id| !id.is_empty())
.map(str::to_string);
Ok(DirectThreadHistorySlice {
items,
has_more,
item_timestamps,
oldest_item_id,
})
})
.await
@@ -1591,7 +1591,7 @@ const DEFAULT_GAME_CREATOR_LLM_REASONING_EFFORT: &str = "high";
const DEFAULT_GAME_CREATOR_LLM_CONTEXT_WINDOW_TOKENS: u64 = 128_000;
const DEFAULT_GAME_CREATOR_LLM_AUTO_COMPACT_TOKEN_LIMIT: u64 = 64_000;
const DEFAULT_GAME_CREATOR_LLM_TOOL_OUTPUT_TOKEN_LIMIT: u64 = 12_000;
const DEFAULT_GAME_CREATOR_LLM_MAX_RETRIES: u32 = 2;
const DEFAULT_GAME_CREATOR_LLM_MAX_RETRIES: u32 = 10;
fn default_game_creator_agent_mode() -> String {
GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER.to_string()
@@ -767,7 +767,16 @@ fn validate_resource_edit_uuid(value: &str, label: &str) -> Result<(), String> {
Ok(())
}
fn resource_edit_prompt_max_chars(edit_kind: &LocalProjectResourceEditKind) -> usize {
/// 资源编辑提示词上限的**唯一口径**。
///
/// 三个调用方都必须从这里取数,禁止各自写死数字:
/// 1. 本文件的提交校验(`normalize_resource_edit_prompt`);
/// 2. `agc_tools` MCP 工具层(`direct_tools_mcp.rs` 的参数校验与工具 schema);
/// 3. 客户端受控工具桥(`direct_tool_bridge.rs`)。
///
/// 客户端 UI 的 `resourceEditPromptMaxLength``resourceEditModel.ts`)是同一份口径的
/// 前端镜像;改数字必须同时改这里、那里,以及工具 schema 里按 kind 声明 `maxLength`。
pub(crate) fn resource_edit_prompt_max_chars(edit_kind: &LocalProjectResourceEditKind) -> usize {
match edit_kind {
LocalProjectResourceEditKind::BackgroundMusic => 140,
LocalProjectResourceEditKind::SoundEffect => 1_900,
@@ -778,6 +787,24 @@ fn resource_edit_prompt_max_chars(edit_kind: &LocalProjectResourceEditKind) -> u
}
}
/// 提示词超限的拒绝文案:与上限同一个口径,MCP 层、工具桥和提交校验复用同一条字符串,
/// 保证模型看到的数字就是真实生效的数字。
pub(crate) fn resource_edit_prompt_limit_error(
edit_kind: &LocalProjectResourceEditKind,
max_chars: usize,
) -> String {
format!(
"{}资源编辑提示词必须在 1..={max_chars} 字符内",
match edit_kind {
LocalProjectResourceEditKind::BackgroundMusic => "背景音乐",
LocalProjectResourceEditKind::SoundEffect => "音效",
LocalProjectResourceEditKind::Video => "视频",
LocalProjectResourceEditKind::CharacterAnimation => "角色动画",
_ => "",
}
)
}
fn normalize_resource_edit_prompt(
edit_kind: &LocalProjectResourceEditKind,
value: &str,
@@ -785,16 +812,7 @@ fn normalize_resource_edit_prompt(
let value = value.trim();
let max_chars = resource_edit_prompt_max_chars(edit_kind);
if value.is_empty() || value.chars().count() > max_chars {
return Err(format!(
"{}资源编辑提示词必须在 1..={max_chars} 字符内",
match edit_kind {
LocalProjectResourceEditKind::BackgroundMusic => "背景音乐",
LocalProjectResourceEditKind::SoundEffect => "音效",
LocalProjectResourceEditKind::Video => "视频",
LocalProjectResourceEditKind::CharacterAnimation => "角色动画",
_ => "",
}
));
return Err(resource_edit_prompt_limit_error(edit_kind, max_chars));
}
if value
.chars()
@@ -24,6 +24,9 @@ pub(crate) struct LocalProjectTextPreview {
pub(crate) media_type: String,
pub(crate) byte_len: u64,
pub(crate) content: String,
/// 仅由已登记资源的原生 UI State 校验设置;前端不根据正文猜测编辑能力。
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) ui_design_asset_id: Option<String>,
}
#[derive(Debug, Eq, PartialEq, Serialize)]
@@ -163,6 +166,7 @@ pub(crate) fn load_local_project_text_preview_with_cancellation(
media_type: media_type.to_string(),
byte_len: content.len() as u64,
content,
ui_design_asset_id: None,
})
}
@@ -315,6 +315,19 @@ fn ui_design_asset(
root: &Path,
expected_project_id: &str,
asset_id: &str,
) -> Result<GameCreationAppAssetManifestEntry, String> {
let asset = registered_json_asset(root, expected_project_id, asset_id)?;
// 新状态初始化仍是显式 UI 创建动作,不能因放开已有设计的登记标签而覆盖普通 JSON。
if asset.kind != UI_DESIGN_DOC_ASSET_KIND || asset.media_type != UI_DESIGN_DOC_MEDIA_TYPE {
return Err("目标资源不是 UI 设计 JSON 资产".to_string());
}
Ok(asset)
}
fn registered_json_asset(
root: &Path,
expected_project_id: &str,
asset_id: &str,
) -> Result<GameCreationAppAssetManifestEntry, String> {
let manifest = read_existing_manifest_for_project(root)?;
if manifest.project_id != expected_project_id {
@@ -325,8 +338,10 @@ 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 {
return Err("目标资源不是 UI 设计 JSON 资产".to_string());
if !asset.local_path.to_ascii_lowercase().ends_with(".json")
|| !is_supported_project_text_resource(&asset.local_path, &asset.media_type)
{
return Err("目标资源不是已登记的 JSON 资产".to_string());
}
normalize_relative_path(&asset.local_path)?;
Ok(asset)
@@ -397,10 +412,27 @@ fn read_ui_design_document_path(path: &Path) -> Result<PersistedUiDesignState, S
"UI 设计 State 超过 {UI_DESIGN_STATE_MAX_BYTES} 字节上限"
));
}
let value: serde_json::Value = serde_json::from_slice(&bytes)
.map_err(|error| format!("解析 UI 设计 State 失败:{}: {error}", path.display()))?;
parse_ui_design_document(&bytes)
.map_err(|error| format!("读取 UI 设计 State 失败:{}: {error}", path.display()))
}
/// 复用编辑器完整契约识别已读取的 JSON;不重读文件,也不触发恢复或任何写入。
pub(crate) fn is_valid_ui_design_json(content: &str, project_id: &str, asset_id: &str) -> bool {
parse_ui_design_document(content.as_bytes())
.and_then(|document| validate_document(&document, project_id, asset_id))
.is_ok()
}
fn parse_ui_design_document(bytes: &[u8]) -> Result<PersistedUiDesignState, String> {
if bytes.len() > UI_DESIGN_STATE_MAX_BYTES {
return Err(format!(
"UI 设计 State 超过 {UI_DESIGN_STATE_MAX_BYTES} 字节上限"
));
}
let value: serde_json::Value = serde_json::from_slice(bytes)
.map_err(|error| format!("解析 UI 设计 State 失败:{error}"))?;
let document: PersistedUiDesignState = serde_json::from_value(value.clone())
.map_err(|error| format!("解析 UI 设计 State 契约失败:{}: {error}", path.display()))?;
.map_err(|error| format!("解析 UI 设计 State 契约失败:{error}"))?;
let canonical: serde_json::Value =
serde_json::from_slice(&serialize_ui_design_document(&document)?)
.map_err(|error| format!("序列化 UI 设计 State 契约失败:{error}"))?;
@@ -851,6 +883,168 @@ mod tests {
}
}
#[test]
fn json_preview_recognition_requires_canonical_state_and_resource_identity() {
let document = serde_json::to_value(empty_document(PROJECT_ID, "design")).unwrap();
let content = document.to_string();
assert!(is_valid_ui_design_json(&content, PROJECT_ID, "design"));
assert!(!is_valid_ui_design_json(
&content,
"other-project",
"design"
));
assert!(!is_valid_ui_design_json(
&content,
PROJECT_ID,
"other-asset"
));
for content in ["{broken", "{}", "[]", r#"{"type":"UI"}"#] {
assert!(!is_valid_ui_design_json(content, PROJECT_ID, "design"));
}
for (pointer, value) in [
("/schemaVersion", serde_json::json!("unknown-schema")),
("/revision", serde_json::json!(9_007_199_254_740_992u64)),
("/state/ui_trees", serde_json::json!([{}])),
] {
let mut invalid = document.clone();
*invalid.pointer_mut(pointer).unwrap() = value;
assert!(!is_valid_ui_design_json(
&invalid.to_string(),
PROJECT_ID,
"design"
));
}
let mut unknown = document.clone();
unknown["state"]["unknown"] = serde_json::json!(true);
assert!(!is_valid_ui_design_json(
&unknown.to_string(),
PROJECT_ID,
"design"
));
let invalid_state = PersistedUiDesignState {
state: state_with_unavailable_image("../outside.png"),
..empty_document(PROJECT_ID, "design")
};
assert!(!is_valid_ui_design_json(
&serde_json::to_string(&invalid_state).unwrap(),
PROJECT_ID,
"design",
));
assert!(!is_valid_ui_design_json(
&" ".repeat(UI_DESIGN_STATE_MAX_BYTES + 1),
PROJECT_ID,
"design",
));
}
#[test]
fn json_preview_recognizes_registered_json_and_editor_requires_canonical_kind() {
for (kind, editor_allowed) in [
(GameCreationAppAssetKind::UiDesignDoc, true),
(GameCreationAppAssetKind::Document, false),
] {
let (directory, asset_id) = fixture();
crate::project::mutate_manifest_at(directory.path(), |manifest| {
manifest
.assets
.iter_mut()
.find(|asset| asset.id == asset_id)
.unwrap()
.kind = kind;
Ok(())
})
.unwrap();
let path = directory.path().join("ui/design.json");
let before = fs::read(&path).unwrap();
let preview = read_local_project_text_preview_at(
directory.path().to_str().unwrap(),
"ui/design.json",
&crate::resource_preview_scheduler::ProjectResourcePreviewScopeCancellation::uncancelled(),
).unwrap();
assert_eq!(
preview.ui_design_asset_id.as_deref(),
Some(asset_id.as_str())
);
assert_eq!(fs::read(&path).unwrap(), before);
let loaded = load_ui_design_state_at(LoadUiDesignStateInput {
project_path: directory.path().to_string_lossy().into_owned(),
expected_project_id: PROJECT_ID.to_string(),
asset_id: asset_id.clone(),
});
assert_eq!(loaded.is_ok(), editor_allowed);
let saved = save_ui_design_state_at(input(
directory.path(),
&asset_id,
0,
state_with_unavailable_image("assets/page.png"),
));
assert_eq!(saved.is_ok(), editor_allowed);
assert_eq!(
read_existing_manifest_for_project(directory.path())
.unwrap()
.assets
.iter()
.find(|asset| asset.id == asset_id)
.unwrap()
.kind,
kind,
);
}
}
#[test]
fn ordinary_or_foreign_json_preview_never_grants_ui_editing_or_overwrites_content() {
let (directory, asset_id) = fixture();
crate::project::mutate_manifest_at(directory.path(), |manifest| {
manifest
.assets
.iter_mut()
.find(|asset| asset.id == asset_id)
.unwrap()
.kind = GameCreationAppAssetKind::Document;
Ok(())
})
.unwrap();
let path = directory.path().join("ui/design.json");
let foreign = serde_json::to_string(&empty_document("other-project", &asset_id)).unwrap();
for content in [r#"{"ordinary":true}"#, "{broken", foreign.as_str()] {
fs::write(&path, content).unwrap();
let preview = read_local_project_text_preview_at(
directory.path().to_str().unwrap(),
"ui/design.json",
&crate::resource_preview_scheduler::ProjectResourcePreviewScopeCancellation::uncancelled(),
).unwrap();
assert_eq!(preview.ui_design_asset_id, None);
assert_eq!(preview.content, content);
assert!(load_ui_design_state_at(LoadUiDesignStateInput {
project_path: directory.path().to_string_lossy().into_owned(),
expected_project_id: PROJECT_ID.to_string(),
asset_id: asset_id.clone(),
})
.is_err());
assert!(save_ui_design_state_at(input(
directory.path(),
&asset_id,
0,
empty_document(PROJECT_ID, &asset_id).state,
))
.is_err());
assert!(
initialize_ui_design_state_at(directory.path(), PROJECT_ID, &asset_id).is_err()
);
assert_eq!(fs::read_to_string(&path).unwrap(), content);
}
fs::write(
directory.path().join("ui/unregistered.json"),
serde_json::to_string(&empty_document(PROJECT_ID, &asset_id)).unwrap(),
)
.unwrap();
assert!(read_local_project_text_preview_at(
directory.path().to_str().unwrap(), "ui/unregistered.json",
&crate::resource_preview_scheduler::ProjectResourcePreviewScopeCancellation::uncancelled(),
).is_err());
}
fn state_with_unavailable_image(path: &str) -> State {
serde_json::from_value(serde_json::json!({
"ui_trees": [],
+52 -27
View File
@@ -239,10 +239,11 @@ import { DeveloperProjectPanels } from './features/project-workspace/DeveloperPr
import { DeveloperRuntimePanels } from './features/project-workspace/DeveloperRuntimePanels';
import {
type DirectThreadConsumeResult,
directThreadHistoryItemsToMessages,
directThreadHistoryPage,
type DirectThreadHistorySlice,
type DirectThreadSubscriptionBootstrap,
isDirectTurnInProgress,
prependDirectHistoryMessages,
} from './features/project-workspace/directThreadEvents';
import { normalizeDirectTimestamp } from './features/project-workspace/directTurnPresentation';
import type { DirectCodexUserContentPart } from './features/project-workspace/generated';
@@ -625,7 +626,7 @@ function claimInitialSupervisorMessageForPage(projectPath: string, scope = '') {
* **** messages latch user
* replace
*
* messages "运行时拥有、且回读结果里没有"
* messages "非历史来源、运行时拥有、且回读结果里没有"
*/
function mergeLoadedConversationWithPendingRuntimeMessages(
loaded: ChatMessage[],
@@ -643,7 +644,7 @@ function mergeLoadedConversationWithPendingRuntimeMessages(
loaded.map((message) => `${message.role}\u0000${message.text}`),
);
const pending = current.filter((message) => {
if (!message.runtimeOwned) {
if (!message.runtimeOwned || message.fromHistory) {
return false;
}
if (message.messageId) {
@@ -1958,7 +1959,7 @@ export function App({
);
const [directHistoryHasMore, setDirectHistoryHasMore] = useState(false);
const directHistoryOldestItemIdRef = useRef<string | null>(null);
const directHistoryLoadingRef = useRef(false);
const directHistoryLoadingRef = useRef<number | null>(null);
const [pendingCommand, setPendingCommand] = useState<PendingCommand | null>(
null,
);
@@ -2151,6 +2152,7 @@ export function App({
function resetProjectSupervisorState() {
projectSupervisorHistoryLoadVersionRef.current += 1;
directHistoryLoadingRef.current = null;
projectSupervisorRuntimeResumeProjectPathRef.current = null;
projectSupervisorSessionIdRef.current = null;
projectSupervisorRuntimeRef.current = null;
@@ -4009,6 +4011,8 @@ export function App({
}
const loadVersion = projectSupervisorHistoryLoadVersionRef.current + 1;
projectSupervisorHistoryLoadVersionRef.current = loadVersion;
if (directCodexProductRuntime)
directHistoryLoadingRef.current = loadVersion;
try {
// V2 projects do not have a Supervisor run or legacy conversation. Probe the
// V2 authority first; a missing V2 session returns null and preserves the
@@ -4105,6 +4109,7 @@ export function App({
: await readProjectSupervisorActiveSession(invoke, nextProjectPath);
let runtimeError = '';
let loadedDirectHistoryHasMore = false;
let loadedDirectHistoryCursor: string | null = null;
const projectConversation = directCodexProductRuntime
? (() => {
return invoke<DirectThreadHistorySlice>(
@@ -4112,16 +4117,16 @@ export function App({
{
projectPath: nextProjectPath,
limit: CONVERSATION_INITIAL_VISIBLE_COUNT,
messagesOnly: true,
},
).then((slice) => {
loadedDirectHistoryHasMore = slice.hasMore;
const page = directThreadHistoryPage(slice);
loadedDirectHistoryHasMore = page.hasMore;
loadedDirectHistoryCursor = page.cursor;
return {
path: nextProjectPath,
agentId: null,
messages: directThreadHistoryItemsToMessages(
slice.items,
slice.itemTimestamps,
),
messages: page.messages,
} satisfies LocalConversationResult;
});
})()
@@ -4197,7 +4202,7 @@ export function App({
const conversationMessages = mergeProjectSupervisorConversation(
resolvedProjectConversation.messages,
supervisorConversation?.messages ?? [],
);
).map((message) => ({ ...message, fromHistory: true }));
if (
conversationContainsProjectSupervisorResponseStream(
supervisorConversation?.messages ?? [],
@@ -4218,9 +4223,7 @@ export function App({
setProjectSupervisorRuntimeError(runtimeError || resumeError);
if (directCodexProductRuntime) {
setDirectHistoryHasMore(loadedDirectHistoryHasMore);
directHistoryOldestItemIdRef.current =
conversationMessages.find((message) => message.messageId)
?.messageId ?? null;
directHistoryOldestItemIdRef.current = loadedDirectHistoryCursor;
}
setMessages((current) => {
// replace 分支同样不能丢掉尚未落盘的运行时消息(初始需求)。
@@ -4281,6 +4284,10 @@ export function App({
: workspaceStatus,
);
// Keep the default greeting when history is missing or blocked.
} finally {
if (directHistoryLoadingRef.current === loadVersion) {
directHistoryLoadingRef.current = null;
}
}
}
@@ -4396,6 +4403,9 @@ export function App({
setAgentRunHistoryFiles([]);
setAgentRuntimeById({});
setMessages(conversationMessages);
// 只有新项目确实打开后才丢弃旧分页位置;打开失败时旧会话仍可继续翻页。
directHistoryOldestItemIdRef.current = null;
setDirectHistoryHasMore(false);
setConversationVisibleCount(CONVERSATION_INITIAL_VISIBLE_COUNT);
savedConversationProjectPathRef.current = openedProject.projectPath;
savedConversationCountRef.current = conversationMessages.length;
@@ -12480,48 +12490,63 @@ export function App({
: null;
async function showEarlierConversationMessages() {
if (hiddenConversationCount > 0) {
setConversationVisibleCount((current) =>
Math.min(messages.length, current + CONVERSATION_VISIBLE_STEP),
);
return;
}
if (directCodexProductRuntime && directHistoryHasMore) {
const invoke = resolveTauriInvoke();
const projectPath = localProject?.projectPath;
if (invoke && projectPath && !directHistoryLoadingRef.current) {
directHistoryLoadingRef.current = true;
if (invoke && projectPath && directHistoryLoadingRef.current === null) {
const loadVersion = projectSupervisorHistoryLoadVersionRef.current;
const beforeItemId = directHistoryOldestItemIdRef.current;
directHistoryLoadingRef.current = loadVersion;
const isCurrentLoad = () =>
manifestRefreshMountedRef.current &&
localProjectPathRef.current === projectPath &&
projectSupervisorHistoryLoadVersionRef.current === loadVersion;
try {
const slice = await invoke<DirectThreadHistorySlice>(
'read_direct_project_history_slice',
{
projectPath,
beforeItemId: directHistoryOldestItemIdRef.current,
beforeItemId,
limit: CONVERSATION_VISIBLE_STEP,
messagesOnly: true,
},
);
if (localProjectPathRef.current !== projectPath) {
if (!isCurrentLoad()) {
return;
}
const older = directThreadHistoryItemsToMessages(
slice.items,
slice.itemTimestamps,
).map((message) => ({
const page = directThreadHistoryPage(slice, beforeItemId);
const older = page.messages.map((message) => ({
role:
message.role === 'user'
? ('user' as const)
: ('assistant' as const),
text: message.content,
runtimeOwned: true,
fromHistory: true,
messageId: message.messageId,
updatedAt: message.updatedAt,
}));
setMessages((current) => [...older, ...current]);
setMessages((current) =>
prependDirectHistoryMessages(current, older),
);
setConversationVisibleCount((current) => current + older.length);
setDirectHistoryHasMore(slice.hasMore);
directHistoryOldestItemIdRef.current =
older.find((message) => message.messageId)?.messageId ??
directHistoryOldestItemIdRef.current;
setDirectHistoryHasMore(page.hasMore);
directHistoryOldestItemIdRef.current = page.cursor;
} catch (error) {
if (!isCurrentLoad()) return;
setWorkspaceStatus(
`读取更早的对话历史失败:${error instanceof Error ? error.message : String(error)}`,
);
} finally {
directHistoryLoadingRef.current = false;
if (directHistoryLoadingRef.current === loadVersion) {
directHistoryLoadingRef.current = null;
}
}
}
return;
@@ -999,6 +999,8 @@ export interface ChatMessage {
agentId?: string | null;
updatedAt?: number;
runtimeOwned?: boolean;
/** 来自历史回读,不作为尚未落盘的实时消息追加到新历史页末尾。 */
fromHistory?: boolean;
}
export type DesignAgentInput =
@@ -1,4 +1,11 @@
import { Fragment, useCallback, useEffect, useRef, useState } from 'react';
import {
Fragment,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from 'react';
import { createPortal } from 'react-dom';
import { launcherNotifications } from '../../app/constants';
@@ -201,12 +208,18 @@ export function WorkspaceLauncherShell({
*/
const manifestMergeNoticeScopeRef = useRef<string | null>(null);
// 打开项目由创建流程提供,其函数引用随渲染变化;标题栏只持有稳定的转发入口,
// 否则发布 Context 会再次触发工作台 effect,形成发布/清理循环。
const openProjectRef = useRef(openProject);
useLayoutEffect(() => {
openProjectRef.current = openProject;
}, [openProject]);
const openActiveProject = useCallback(
(nextProjectPath: string) => {
setProjectPath(nextProjectPath);
void openProject(nextProjectPath, 'open');
void openProjectRef.current(nextProjectPath, 'open');
},
[openProject, setProjectPath],
[setProjectPath],
);
useEffect(() => {
@@ -216,7 +229,6 @@ export function WorkspaceLauncherShell({
readFailed: snapshotReadFailed,
onOpenProject: openActiveProject,
});
return () => setActiveProjectRuns(null);
}, [
activeTurns,
currentProjectContext?.projectPath,
@@ -224,6 +236,7 @@ export function WorkspaceLauncherShell({
setActiveProjectRuns,
snapshotReadFailed,
]);
useEffect(() => () => setActiveProjectRuns(null), [setActiveProjectRuns]);
useEffect(() => {
const projectPath = currentProjectContext?.projectPath ?? null;
@@ -1,4 +1,7 @@
import type { LocalConversationMessageRecord } from '../../app/types';
import type {
ChatMessage,
LocalConversationMessageRecord,
} from '../../app/types';
export type DirectThreadRawEvent = {
seq: number;
@@ -22,8 +25,54 @@ export type DirectThreadHistorySlice = {
items: unknown[];
hasMore: boolean;
itemTimestamps?: Record<string, number>;
oldestItemId?: string | null;
};
/** 游标取原始响应,而非过滤后的聊天消息;拒绝不能前进的页,避免静默反复回读。 */
export function directThreadHistoryPage(
slice: DirectThreadHistorySlice,
previousCursor: string | null = null,
) {
const first = slice.items[0];
const firstId =
first && typeof first === 'object' && 'id' in first
? (first as { id?: unknown }).id
: null;
const cursor =
slice.oldestItemId ??
(typeof firstId === 'string' && firstId ? firstId : null);
if (slice.hasMore && (!cursor || cursor === previousCursor)) {
throw new Error('对话历史分页游标未前进,请重新读取项目历史');
}
return {
messages: directThreadHistoryItemsToMessages(
slice.items,
slice.itemTimestamps,
),
hasMore: slice.hasMore,
cursor,
};
}
/** 保留当前实时/已显示版本;原始身份相同的回读消息不能插入第二次。 */
export function prependDirectHistoryMessages(
current: readonly ChatMessage[],
older: readonly ChatMessage[],
): ChatMessage[] {
const ids = new Set(
current.flatMap((message) =>
message.messageId ? [message.messageId] : [],
),
);
const additions = older.filter((message) => {
if (!message.messageId) return true;
if (ids.has(message.messageId)) return false;
ids.add(message.messageId);
return true;
});
return [...additions, ...current];
}
export function directThreadHistoryItemsToMessages(
items: unknown[],
itemTimestamps: Readonly<Record<string, number>> = {},
@@ -37,6 +37,23 @@ export function isResourceCanvasInteractionTarget(
return Boolean(target?.closest(RESOURCE_CANVAS_INTERACTION_SELECTOR));
}
/** 抓手可从卡面发起,但不能抢走输入、媒体控件或画布浮层的交互。 */
export function isResourceCanvasPanTarget(
target: Element | null | undefined,
): boolean {
if (!target) return false;
if (target.closest('[contenteditable="true"], .game-resource-filter-panel')) {
return false;
}
if (!isResourceCanvasInteractionTarget(target)) return true;
return Boolean(
target.closest('.game-resource-card') &&
!target.closest(
'button:not(.game-resource-card-select), input, textarea, select, a, audio, video',
),
);
}
/**
* 画布浮层里有自己滚动区的那几个:落在它们里面的滚轮归浮层,画布不得消费。
*
@@ -1,4 +1,5 @@
import {
isProjectResourceJson,
projectResourceCardPreviewKind,
projectResourcePathExtension,
} from '../../view/project-development/resourceCardPreviewModel';
@@ -65,11 +66,14 @@ export function resourceDocumentPreviewMarkdown(
resource: ProjectResource,
content: string,
) {
if (projectResourceCardPreviewKind(resource) !== 'code') {
const isJson = isProjectResourceJson(resource);
if (projectResourceCardPreviewKind(resource) !== 'code' && !isJson) {
return content;
}
const language =
CODE_LANGUAGES[projectResourcePathExtension(resource.path) ?? ''] ?? 'text';
const language = isJson
? 'json'
: (CODE_LANGUAGES[projectResourcePathExtension(resource.path) ?? ''] ??
'text');
// 围栏长于源码里的任意反引号串,代码生成模板中的 Markdown 不能提前闭合代码块。
const longestRun = (content.match(/`+/g) ?? []).reduce(
(length, run) => Math.max(length, run.length),
@@ -64,7 +64,7 @@ const defaultRuntimeConfigDraft: GameCreatorAppConfig = {
autoCompactTokenLimit: 64000,
toolOutputTokenLimit: 12000,
requestTimeoutMs: 180000,
maxRetries: 2,
maxRetries: 10,
retryBackoffMs: 500,
},
agentLlm: {},
+21
View File
@@ -8820,6 +8820,27 @@ iframe.preview-frame {
overflow: hidden;
}
/* 输入盒的弹层必须能溢出面板控制排最左侧是推理档它的菜单
`.conversation-model-menu` 贴着触发钮右缘向左展开窄布局视口 1000px 时对话面板
只有 280px 下会伸到面板左侧之外`.game-workbench-chat`surfaceconversation
这三层 `overflow: hidden` 会沿着各自的溢出边界把它裁掉档位文字正好落在被裁掉的
那半边于是点开只能看到一个空盒子所以这里让这三层不再裁切菜单自身位置
尺寸都不变只是允许它盖到左侧面板上完整显示消息列表自带 `overflow-y: auto`
另一轴按规范计算为 auto消息内容仍由列表自身裁剪 */
.game-workbench-chat:has(.project-supervisor-composer.is-direct-codex) {
overflow: visible;
}
.game-workbench-chat .project-supervisor-surface.is-direct-codex {
overflow: visible;
}
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-conversation {
overflow: visible;
}
.game-workbench-chat .project-supervisor-message-list {
height: 100%;
min-height: 96px;
@@ -1,10 +1,17 @@
import { FileCode2, Image as ImageIcon, Music2, Video } from 'lucide-react';
import {
FileCode2,
Image as ImageIcon,
Music2,
SlidersHorizontal,
Video,
} from 'lucide-react';
import { useEffect, useRef } from 'react';
import {
projectResourceCardPreviewKind,
type ProjectResourceCardPreviewState,
projectResourceCardPreviewVariant,
projectResourceJsonPresentation,
} from './resourceCardPreviewModel';
import type { ProjectResource } from './resourceProjectionModel';
@@ -90,6 +97,16 @@ export function ResourcePreviewMedia({
const sourceUrl =
preview.status === 'loaded' ? (preview.preview.sourceUrl ?? null) : null;
const visual = (() => {
const jsonPresentation = resource
? projectResourceJsonPresentation(resource, preview)
: null;
if (jsonPresentation) {
return jsonPresentation === 'ui-design' ? (
<SlidersHorizontal className="h-5 w-5" aria-label="UI 设计" />
) : (
<FileCode2 className="h-5 w-5" aria-label="JSON" />
);
}
if (sourceUrl && (kind === 'raster-image' || kind === 'media-image')) {
return (
<img
@@ -80,10 +80,7 @@ import type {
GameIterationVersion,
ProjectResourceCanvasLayoutMode,
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
import {
GAME_CREATION_APP_UI_DESIGN_ASSET_KIND,
GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND,
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
import { GAME_CREATION_APP_UI_DESIGN_ASSET_KIND } from '../../../../../packages/shared/src/contracts/gameCreationApp';
import {
isGameCreationAppAssetKind,
isGameCreationAppUiDesignDocAsset,
@@ -163,6 +160,7 @@ import { ResourceCanvasBottomToolbarView } from '../../features/resource-canvas/
import {
canDismissResourceCanvasQuickEdit,
isResourceCanvasInteractionTarget,
isResourceCanvasPanTarget,
isResourceCanvasWheelOverlayTarget,
resolveResourceCanvasFloatingPanelDismissOpen,
resolveResourceCanvasFloatingPanelOpen,
@@ -306,6 +304,7 @@ import {
projectResourceCardPreviewVariant,
projectResourceCodeTypeLabel,
projectResourceDocumentPreviewText,
projectResourceJsonPresentation,
} from './resourceCardPreviewModel';
import { ResourceClassificationPanel } from './ResourceClassificationPanel';
import {
@@ -775,8 +774,8 @@ const ResourceCard = memo(function ResourceCard({
*
* `resource.category`
* `categoryLabels` `resourceReferenceCategoryLabel`
* "图片 / 视频 / 文档"
* / / /
* JSON UI / JSON
* manifest 使
*/
const resourceCategoryLabel = categoryLabels[resource.category];
const isMedia = kind === 'video' || kind === 'audio';
@@ -790,9 +789,17 @@ const ResourceCard = memo(function ResourceCard({
* "丑预览" IO
*/
const previewVariant = projectResourceCardPreviewVariant(resource);
const jsonPresentation = projectResourceJsonPresentation(resource, preview);
const cardTypeLabel =
jsonPresentation === 'ui-design'
? 'UI 设计'
: jsonPresentation === 'json'
? 'JSON'
: resourceCategoryLabel;
/** 代码卡的类型标签(`.tsx` → `TSX`);不是代码卡时为 `null`。 */
const codeTypeLabel = projectResourceCodeTypeLabel(resource.path);
const documentPreview =
!jsonPresentation &&
preview.status === 'loaded' &&
preview.preview.content !== undefined &&
previewVariant !== null &&
@@ -831,6 +838,20 @@ const ResourceCard = memo(function ResourceCard({
}, [previewIdentity, sourceUrl]);
const visual = (() => {
if (jsonPresentation) {
return (
<span className="game-resource-card-code-visual">
{jsonPresentation === 'ui-design' ? (
<SlidersHorizontal size={30} aria-hidden="true" />
) : (
<FileCode2 size={30} aria-hidden="true" />
)}
<span className="game-resource-card-code-label">
{jsonPresentation === 'ui-design' ? 'UI 编辑器' : 'JSON'}
</span>
</span>
);
}
if ((kind === 'raster-image' || kind === 'media-image') && sourceUrl) {
return (
<img
@@ -1006,6 +1027,7 @@ const ResourceCard = memo(function ResourceCard({
}`}
data-resource-card-id={resource.id}
data-preview-kind={kind}
data-json-presentation={jsonPresentation ?? undefined}
// 占位分支对「还没读 / 读失败 / 压根不适用」给的是同一个图标、且卡面没有文字提示,
// 现场无法区分「为什么没有图」。这里把预览状态与失败原因暴露到 DOM,
// 让排障只需读一个属性,而不是去猜是调度没发请求还是原生拒绝了读取。
@@ -1058,10 +1080,10 @@ const ResourceCard = memo(function ResourceCard({
</span>
<span
className="game-resource-card-type-badge"
data-resource-type={resourceCategoryLabel}
title={resourceCategoryLabel}
data-resource-type={cardTypeLabel}
title={cardTypeLabel}
>
{resourceCategoryLabel}
{cardTypeLabel}
</span>
{lineage ? (
// 文字是给人看的关系,`data-resource-lineage` 是给端到端验收的稳定判据
@@ -1351,6 +1373,7 @@ function ResourceBookScene({
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerCancel}
onLostPointerCapture={onPointerCancel}
>
<div
className="game-resource-book-scene-world"
@@ -1494,6 +1517,7 @@ export default function ProjectDevelopmentView({
});
const resourceBookMainPanRef = useRef<{
pointerId: number;
captureTarget: HTMLElement;
startClientX: number;
startClientY: number;
startViewport: CanvasViewport;
@@ -1780,6 +1804,7 @@ export default function ProjectDevelopmentView({
const resourceCanvasFitKeysRef = useRef<Set<string>>(new Set());
const resourceCanvasPanRef = useRef<{
pointerId: number;
captureTarget: HTMLElement;
category: ResourceBookTarget;
startClientX: number;
startClientY: number;
@@ -3372,15 +3397,14 @@ export default function ProjectDevelopmentView({
);
const cancelResourceCanvasPan = useCallback(() => {
const pan = resourceCanvasPanRef.current;
if (!pan) {
return;
}
const canvas = resourceCanvasRef.current;
if (canvas?.hasPointerCapture?.(pan.pointerId)) {
canvas.releasePointerCapture?.(pan.pointerId);
}
const pans = [resourceCanvasPanRef.current, resourceBookMainPanRef.current];
resourceCanvasPanRef.current = null;
resourceBookMainPanRef.current = null;
for (const pan of pans) {
if (pan?.captureTarget.hasPointerCapture?.(pan.pointerId)) {
pan.captureTarget.releasePointerCapture(pan.pointerId);
}
}
}, []);
const cancelResourceCardDrag = useCallback(() => {
@@ -4758,6 +4782,8 @@ export default function ProjectDevelopmentView({
};
const handleBlur = () => {
resourceCanvasSpacePanRef.current = false;
cancelResourceCanvasPan();
setResourceCanvasMarquee(null);
};
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('keyup', handleKeyUp);
@@ -4767,7 +4793,7 @@ export default function ProjectDevelopmentView({
window.removeEventListener('keyup', handleKeyUp);
window.removeEventListener('blur', handleBlur);
};
}, []);
}, [cancelResourceCanvasPan]);
const handleResourceBookWheel = useCallback(
(event: ReactWheelEvent<HTMLDivElement> | WheelEvent) => {
@@ -4864,7 +4890,10 @@ export default function ProjectDevelopmentView({
const handleResourceBookMainPointerDown = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
if (resourceBookView !== 'main' || event.button !== 0) {
if (
resourceBookView !== 'main' ||
(event.button !== 0 && event.button !== 2)
) {
return;
}
const target = event.target as HTMLElement;
@@ -4880,6 +4909,7 @@ export default function ProjectDevelopmentView({
resourceBookTransitionControllerRef.current.settle();
resourceBookMainPanRef.current = {
pointerId: event.pointerId,
captureTarget: event.currentTarget,
startClientX: event.clientX,
startClientY: event.clientY,
startViewport: resourceBookMainViewportRef.current,
@@ -5105,11 +5135,19 @@ export default function ProjectDevelopmentView({
const canvasTarget = resourceBookOpensAllResources
? RESOURCE_BOOK_ALL_TARGET
: activePageCategory;
if (!canvasTarget || event.button > 1) {
if (!canvasTarget || event.button > 2) {
return;
}
const target = event.target as HTMLElement;
if (isResourceCanvasInteractionTarget(target)) {
const isPan =
event.button === 2 ||
event.button === 1 ||
resourceCanvasSpacePanRef.current;
if (
event.button === 2
? !isResourceCanvasPanTarget(target)
: isResourceCanvasInteractionTarget(target)
) {
return;
}
// 点选态下空白处的左键不起框选、也不清画布焦点/选中:点选只认资源卡,
@@ -5123,12 +5161,13 @@ export default function ProjectDevelopmentView({
return;
}
resourceBookTransitionControllerRef.current.settle();
// 与美术画布一致:中键或按住空格是平移,左键在空白处是框选。
if (event.button === 1 || resourceCanvasSpacePanRef.current) {
// 右键平移,保留中键/空格抓手;空白处左键继续框选。
if (isPan) {
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
resourceCanvasPanRef.current = {
pointerId: event.pointerId,
captureTarget: event.currentTarget,
category: canvasTarget,
startClientX: event.clientX,
startClientY: event.clientY,
@@ -5300,12 +5339,25 @@ export default function ProjectDevelopmentView({
const openResourceUiEditor = useCallback(
(resource: ProjectResource) => {
if (resource.subtype === GAME_CREATION_APP_UI_DESIGN_ASSET_KIND) {
const identity = resourceCardPreviews.identityByResourceId.get(
resource.id,
);
const jsonPresentation = projectResourceJsonPresentation(
resource,
identity ? resourceCardPreviews.previews.get(identity) : null,
);
if (
resource.subtype === GAME_CREATION_APP_UI_DESIGN_ASSET_KIND &&
jsonPresentation === null
) {
void openUiDesignEditor(resource);
return;
}
if (resource.manifestAssetId === null) {
setResourceWorkbenchNotice('该 UI 资源缺少有效的正式资产身份');
if (
resource.manifestAssetId === null ||
jsonPresentation !== 'ui-design'
) {
setResourceWorkbenchNotice('该资源尚未通过 UI 设计 JSON 校验');
return;
}
canvasOpenEpochRef.current += 1;
@@ -5326,7 +5378,13 @@ export default function ProjectDevelopmentView({
: {}),
});
},
[advanceFocusGeneration, manifest.assets, openUiDesignEditor],
[
advanceFocusGeneration,
manifest.assets,
openUiDesignEditor,
resourceCardPreviews.identityByResourceId,
resourceCardPreviews.previews,
],
);
/**
@@ -6209,8 +6267,7 @@ export default function ProjectDevelopmentView({
setMode('run');
}
const showRunUnavailableHint =
!runAvailable && selectedResourceIds.length === 0 && !uiEditorRoute;
const showRunUnavailableHint = !runAvailable && !uiEditorRoute;
const renderResourceBookCard = useCallback(
(
@@ -7468,9 +7525,16 @@ export default function ProjectDevelopmentView({
: null,
[manifest, selectedResource],
);
const selectedResourceJsonPresentation = selectedResource
? projectResourceJsonPresentation(
selectedResource,
selectedResourceCardPreview,
)
: null;
const selectedResourceOpensUiEditor =
selectedResource?.subtype === GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND ||
selectedResource?.subtype === GAME_CREATION_APP_UI_DESIGN_ASSET_KIND;
selectedResourceJsonPresentation === 'ui-design' ||
(selectedResourceJsonPresentation === null &&
selectedResource?.subtype === GAME_CREATION_APP_UI_DESIGN_ASSET_KIND);
const selectedToolbarStyle = selectedResourceLayer
? resolveSelectedToolbarStyle({
@@ -7858,10 +7922,24 @@ export default function ProjectDevelopmentView({
className={`game-resource-manager game-resource-book-manager game-resource-book-manager--${resourceBookState.view} game-resource-book-manager--${resourceBookState.phase}`}
data-resource-book-view={resourceBookState.view}
data-resource-book-transition={resourceBookState.phase}
onContextMenu={(event) => {
const target = event.target as Element;
if (
event.button === 2 &&
event.currentTarget.contains(target) &&
target.closest(
'.game-resource-book-scene, .game-resource-canvas, .game-resource-book-main',
) &&
isResourceCanvasPanTarget(target)
) {
event.preventDefault();
}
}}
onPointerDownCapture={(event) => {
if (
event.currentTarget.dataset.resourceBookTransition !==
'idle' &&
event.button === 0 &&
(event.target as Element).closest('.game-resource-card')
) {
event.preventDefault();
@@ -7935,7 +8013,8 @@ export default function ProjectDevelopmentView({
<span></span>
</CanvasChromeButton>
) : null}
{selectedResourceOpensUiEditor ? (
{selectedResource &&
selectedResourceOpensUiEditor ? (
<CanvasChromeButton
className="image-canvas-editor__floating-toolbar-text-button"
label="UI 编辑器"
@@ -8517,6 +8596,7 @@ export default function ProjectDevelopmentView({
onPointerMove={handleResourceCanvasPointerMove}
onPointerUp={stopResourceCanvasPan}
onPointerCancel={stopResourceCanvasPan}
onLostPointerCapture={stopResourceCanvasPan}
>
{sortMode === 'dependency' &&
dependencyRelationshipDescriptions.length > 0 ? (
@@ -58,6 +58,8 @@ export type ProjectResourceCardPreviewPayload = {
hasAlpha?: boolean;
sourceUrl?: string;
content?: string;
/** 原生侧完整校验过的 UI State 资产身份,不由前端解析 JSON 推断。 */
uiDesignAssetId?: string;
};
export type ProjectResourceCardPreviewTransportPayload = Omit<
@@ -219,7 +221,8 @@ const markdownExtension = /\.(md|markdown|mdx)$/iu;
* 与 `resourceProjectionModel` 的 `gameCodeExtension` 是两份口径,刻意不复用:
* 那份用于**筛选与归属**,改动会波及画布栏目与计数;这份只决定**卡面怎么画**。
* 这里按用户口径把 `.yaml` / `.toml` / `.xml` / `.html` / `.css` / `.sql`
* 一并算代码JSON 规格属于文档预览。
* 一并算代码JSON 留在文本读取通道,由原生内容识别区分普通 JSON 和 UI 设计,
* 不能像源码卡一样跳过预取;卡面不显示 JSON 正文。
*/
const cardCodeExtension =
/\.(ts|tsx|mts|cts|js|jsx|mjs|cjs|rs|py|go|java|kt|kts|cs|cpp|cc|cxx|c|h|hpp|swift|php|rb|lua|sh|bash|zsh|ps1|psm1|ya?ml|toml|xml|html?|css|scss|less|sql|graphql|gql|vue|svelte)$/iu;
@@ -236,6 +239,30 @@ export function projectResourcePathExtension(path: string): string | null {
return matched ? matched[1]!.toLowerCase() : null;
}
export function isProjectResourceJson(
resource: Pick<ProjectResource, 'path' | 'mediaType'>,
): boolean {
return (
projectResourcePathExtension(resource.path) === 'json' ||
(projectResourcePathExtension(resource.path) === null &&
resource.mediaType.toLowerCase().includes('json'))
);
}
/** 读取结果必须仍属于当前卡片;元数据中的 UI 标签本身不能授予编辑入口。 */
export function projectResourceJsonPresentation(
resource: ProjectResource,
preview: ProjectResourceCardPreviewState | null | undefined,
): 'json' | 'ui-design' | null {
if (!isProjectResourceJson(resource)) return null;
return resource.manifestAssetId &&
preview?.status === 'loaded' &&
preview.preview.path === resource.path &&
preview.preview.uiDesignAssetId === resource.manifestAssetId
? 'ui-design'
: 'json';
}
/** 代码文件的类型标签(如 `.ts` → `TS`);不是代码文件时返回 `null`。 */
export function projectResourceCodeTypeLabel(path: string): string | null {
const trimmed = path.trim();
@@ -256,7 +283,7 @@ export function projectResourceCardPreviewKind(
return 'document';
}
// Markdown / 代码在扩展名这一层就分流,不再依赖上游登记类型:
// 上游把 JSON 规格登记成「文档」,卡面按文档预览;代码文件按扩展名分流。
// JSON 使用文档读取通道完成原生语义识别;源码文件按扩展名分流。
if (markdownExtension.test(resource.path)) {
return 'document';
}

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