Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ecaf1e8cec | |||
| fca3df964c | |||
| ac8a51ca79 | |||
| 1b010fb1f8 | |||
| 825ea76b7b | |||
| 910862fd06 |
+1
-1
@@ -14,7 +14,7 @@ Let the client derive projections from real disk changes and trusted tool result
|
|||||||
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.
|
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.
|
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.
|
||||||
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.
|
6. When the user explicitly asks to remove an image background, call `agc_remove_background` with a registered image `sourceLocalAssetId` and an output name. 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.
|
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.
|
8. Do not edit `.agent/manifest.json`, revision counters, version records, resource IDs, canvas identities, source provenance, generation ledgers, or browser receipts by hand.
|
||||||
9. Do not create a version when no game file changed. The client compares content fingerprints and advances revision only after an actual source change.
|
9. Do not create a version when no game file changed. The client compares content fingerprints and advances revision only after an actual source change.
|
||||||
|
|||||||
+1
-1
@@ -14,4 +14,4 @@ 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.
|
`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.
|
||||||
|
|
||||||
`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.
|
`agc_remove_background` is the semantic image post-processing path. It accepts only a registered image `sourceLocalAssetId` and output name; 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. 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",
|
"schemaVersion": "agc-skill-pack.v1",
|
||||||
"version": "2026-08-26.17",
|
"version": "2026-08-26.16",
|
||||||
"skills": [
|
"skills": [
|
||||||
{
|
{
|
||||||
"name": "agc-game-production-workflow",
|
"name": "agc-game-production-workflow",
|
||||||
@@ -123,7 +123,7 @@
|
|||||||
"agents/openai.yaml",
|
"agents/openai.yaml",
|
||||||
"references/projection-contract.md"
|
"references/projection-contract.md"
|
||||||
],
|
],
|
||||||
"sha256": "a929c27bc5b2b0bee0b7935e5c7b04ddbab1eb1804fe196f8c2537ad040ca5b1"
|
"sha256": "96b5bf9e2ed150bbe934a888867c1bb500b214a131f8b36c4830f51ca30267b6"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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> {
|
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);
|
let path = history_path(root);
|
||||||
if !prepare_game_creator_private_path_for_read(&path, false, "DirectProject 历史")? {
|
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)
|
let file = File::open(&path)
|
||||||
.map_err(|error| format!("打开 DirectProject 历史失败:{}: {error}", path.display()))?;
|
.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) {
|
if is_direct_project_internal_context_item(&item) {
|
||||||
continue;
|
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((
|
items.push((
|
||||||
item,
|
item,
|
||||||
parsed
|
parsed
|
||||||
@@ -527,7 +562,45 @@ fn read_direct_project_history_entries_at(root: &Path) -> Result<Vec<(Value, u64
|
|||||||
.unwrap_or(0),
|
.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(
|
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_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":"已完成"}]}}"#;
|
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]
|
#[test]
|
||||||
fn history_timestamps_survive_reload_and_idempotent_append_without_changing_raw_items() {
|
fn history_timestamps_survive_reload_and_idempotent_append_without_changing_raw_items() {
|
||||||
let root = init_history_project("history-time");
|
let root = init_history_project("history-time");
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ pub(crate) struct DirectThreadHistorySlice {
|
|||||||
pub(crate) items: Vec<Value>,
|
pub(crate) items: Vec<Value>,
|
||||||
pub(crate) has_more: bool,
|
pub(crate) has_more: bool,
|
||||||
pub(crate) item_timestamps: std::collections::BTreeMap<String, u64>,
|
pub(crate) item_timestamps: std::collections::BTreeMap<String, u64>,
|
||||||
|
pub(crate) oldest_item_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
|
|||||||
@@ -1887,7 +1887,7 @@ async fn bridge_create_or_derive_resource(
|
|||||||
|
|
||||||
async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Value) -> Value {
|
async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Value) -> Value {
|
||||||
let result = async {
|
let result = async {
|
||||||
super::direct_tools_mcp::validate_remove_background_arguments(arguments)?;
|
bridge_reject_unknown_fields(arguments, &["sourceLocalAssetId", "assetName"])?;
|
||||||
enforce_project_permission_policy(&state.root, "canvas.asset_generate")?;
|
enforce_project_permission_policy(&state.root, "canvas.asset_generate")?;
|
||||||
enforce_project_permission_policy(&state.root, "asset.register")?;
|
enforce_project_permission_policy(&state.root, "asset.register")?;
|
||||||
let source_asset_id = bridge_bounded_string(arguments, "sourceLocalAssetId", 80)?;
|
let source_asset_id = bridge_bounded_string(arguments, "sourceLocalAssetId", 80)?;
|
||||||
@@ -1896,8 +1896,6 @@ async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Val
|
|||||||
"assetName",
|
"assetName",
|
||||||
DIRECT_TOOL_BRIDGE_MAX_RESOURCE_NAME_CHARS,
|
DIRECT_TOOL_BRIDGE_MAX_RESOURCE_NAME_CHARS,
|
||||||
)?;
|
)?;
|
||||||
let background_mode = arguments.get("backgroundMode").and_then(Value::as_str);
|
|
||||||
let screen_color = arguments.get("screenColor").and_then(Value::as_str);
|
|
||||||
let manifest = read_existing_manifest_for_project(&state.root)?;
|
let manifest = read_existing_manifest_for_project(&state.root)?;
|
||||||
let source_asset = manifest
|
let source_asset = manifest
|
||||||
.assets
|
.assets
|
||||||
@@ -1922,34 +1920,22 @@ async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Val
|
|||||||
.map_err(|_| "创建抠图服务连接失败".to_string())?;
|
.map_err(|_| "创建抠图服务连接失败".to_string())?;
|
||||||
let context =
|
let context =
|
||||||
prepare_external_canvas_generation_context(&state.root, &client, &access).await?;
|
prepare_external_canvas_generation_context(&state.root, &client, &access).await?;
|
||||||
let fingerprint = background_removal_request_fingerprint(
|
let fingerprint = format!("{}\0{}", source_asset_id, asset_name);
|
||||||
&source_asset_id,
|
|
||||||
&asset_name,
|
|
||||||
background_mode,
|
|
||||||
screen_color,
|
|
||||||
);
|
|
||||||
let (_operation_id, idempotency_key) = state.resource_request_ids(&fingerprint)?;
|
let (_operation_id, idempotency_key) = state.resource_request_ids(&fingerprint)?;
|
||||||
let route = "/api/external/v1/editor/images/background-removals";
|
let route = "/api/external/v1/editor/images/background-removals";
|
||||||
let mut request_body = json!({
|
|
||||||
"sourceImageSrc": source_resource_id,
|
|
||||||
"projectId": manifest.project_id,
|
|
||||||
"assetKind": source_asset.kind,
|
|
||||||
"assetFolderId": context.asset_folder_id,
|
|
||||||
"assetLabel": asset_name,
|
|
||||||
"sourceResourceId": source_resource_id,
|
|
||||||
});
|
|
||||||
if background_mode == Some("flat") {
|
|
||||||
request_body["backgroundMode"] = json!("flat");
|
|
||||||
}
|
|
||||||
if let Some(color) = screen_color {
|
|
||||||
request_body["screenColor"] = json!(color);
|
|
||||||
}
|
|
||||||
let response = crate::http_client::with_agc_main_site_marker(
|
let response = crate::http_client::with_agc_main_site_marker(
|
||||||
client
|
client
|
||||||
.post(format!("{}{}", api_base_url, route))
|
.post(format!("{}{}", api_base_url, route))
|
||||||
.bearer_auth(api_key)
|
.bearer_auth(api_key)
|
||||||
.header("Idempotency-Key", idempotency_key)
|
.header("Idempotency-Key", idempotency_key)
|
||||||
.json(&request_body),
|
.json(&json!({
|
||||||
|
"sourceImageSrc": source_resource_id,
|
||||||
|
"projectId": manifest.project_id,
|
||||||
|
"assetKind": source_asset.kind,
|
||||||
|
"assetFolderId": context.asset_folder_id,
|
||||||
|
"assetLabel": asset_name,
|
||||||
|
"sourceResourceId": source_resource_id,
|
||||||
|
})),
|
||||||
)
|
)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
@@ -1986,20 +1972,6 @@ async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Val
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn background_removal_request_fingerprint(
|
|
||||||
source: &str,
|
|
||||||
name: &str,
|
|
||||||
mode: Option<&str>,
|
|
||||||
color: Option<&str>,
|
|
||||||
) -> String {
|
|
||||||
let mode = mode.unwrap_or("complex");
|
|
||||||
if mode == "complex" && color.is_none() {
|
|
||||||
format!("{source}\0{name}")
|
|
||||||
} else {
|
|
||||||
format!("{source}\0{name}\0{mode}\0{}", color.unwrap_or(""))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn bridge_safe_queue_state(value: Value) -> Value {
|
fn bridge_safe_queue_state(value: Value) -> Value {
|
||||||
let object = value.as_object();
|
let object = value.as_object();
|
||||||
json!({
|
json!({
|
||||||
@@ -2731,29 +2703,6 @@ pub(crate) async fn start_direct_tool_bridge(
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
#[test]
|
|
||||||
fn remove_background_identity_preserves_default_and_distinguishes_options() {
|
|
||||||
let legacy = "asset-1\0透明图";
|
|
||||||
assert_eq!(
|
|
||||||
background_removal_request_fingerprint("asset-1", "透明图", None, None),
|
|
||||||
legacy
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
background_removal_request_fingerprint("asset-1", "透明图", Some("complex"), None),
|
|
||||||
legacy
|
|
||||||
);
|
|
||||||
let mut identities = std::collections::HashSet::new();
|
|
||||||
identities.insert(legacy.to_string());
|
|
||||||
for color in [None, Some("auto"), Some("#CFEFFF"), Some("#112233")] {
|
|
||||||
let id =
|
|
||||||
background_removal_request_fingerprint("asset-1", "透明图", Some("flat"), color);
|
|
||||||
assert_eq!(
|
|
||||||
id,
|
|
||||||
background_removal_request_fingerprint("asset-1", "透明图", Some("flat"), color)
|
|
||||||
);
|
|
||||||
assert!(identities.insert(id));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use std::io::{Cursor, Read, Write};
|
use std::io::{Cursor, Read, Write};
|
||||||
|
|
||||||
|
|||||||
@@ -435,7 +435,7 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab
|
|||||||
}),
|
}),
|
||||||
json!({
|
json!({
|
||||||
"name": "agc_remove_background",
|
"name": "agc_remove_background",
|
||||||
"description": "为当前项目已登记的图片资源去除背景。complex 通过语义分割识别前景;flat 用于纯色背景抠图,确定背景为纯色时优先选择 flat。提供资源身份、结果名称及可选模式和背景色;客户端管理登录、项目画布和素材目录,不返回 Token、内部路由、宿主路径或临时签名 URL。",
|
"description": "为当前项目已登记的图片资源去除背景。客户端使用当前登录账号的抠图服务、项目画布和素材目录,模型只能提供已登记资源身份与结果名称;不会返回 Token、内部路由、宿主路径或临时签名 URL。",
|
||||||
"inputSchema": {
|
"inputSchema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -449,16 +449,6 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"minLength": 1,
|
"minLength": 1,
|
||||||
"maxLength": DIRECT_TOOLS_MCP_MAX_RESOURCE_NAME_CHARS
|
"maxLength": DIRECT_TOOLS_MCP_MAX_RESOURCE_NAME_CHARS
|
||||||
},
|
|
||||||
"backgroundMode": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": ["complex", "flat"],
|
|
||||||
"description": "可选抠图模式:complex 用语义分割识别前景,flat 用纯色背景抠图;确定背景为纯色时优先使用 flat。省略时使用 complex"
|
|
||||||
},
|
|
||||||
"screenColor": {
|
|
||||||
"type": "string",
|
|
||||||
"pattern": "^(auto|#[0-9A-Fa-f]{6})$",
|
|
||||||
"description": "flat 模式可选背景色;传 auto 或 #RRGGBB,省略时由服务自动检测"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["sourceLocalAssetId", "assetName"],
|
"required": ["sourceLocalAssetId", "assetName"],
|
||||||
@@ -891,46 +881,14 @@ fn validate_resource_generation_arguments(arguments: &Value) -> Result<(), Strin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn validate_remove_background_arguments(arguments: &Value) -> Result<(), String> {
|
fn validate_remove_background_arguments(arguments: &Value) -> Result<(), String> {
|
||||||
validate_tool_object_fields(
|
validate_tool_object_fields(arguments, &["sourceLocalAssetId", "assetName"])?;
|
||||||
arguments,
|
|
||||||
&[
|
|
||||||
"sourceLocalAssetId",
|
|
||||||
"assetName",
|
|
||||||
"backgroundMode",
|
|
||||||
"screenColor",
|
|
||||||
],
|
|
||||||
)?;
|
|
||||||
bounded_tool_string(arguments, "sourceLocalAssetId", 80)?;
|
bounded_tool_string(arguments, "sourceLocalAssetId", 80)?;
|
||||||
bounded_tool_string(
|
bounded_tool_string(
|
||||||
arguments,
|
arguments,
|
||||||
"assetName",
|
"assetName",
|
||||||
DIRECT_TOOLS_MCP_MAX_RESOURCE_NAME_CHARS,
|
DIRECT_TOOLS_MCP_MAX_RESOURCE_NAME_CHARS,
|
||||||
)?;
|
)?;
|
||||||
if let Some(mode) = arguments.get("backgroundMode") {
|
|
||||||
let mode = mode
|
|
||||||
.as_str()
|
|
||||||
.ok_or_else(|| "backgroundMode 必须是 complex 或 flat".to_string())?;
|
|
||||||
if mode != "complex" && mode != "flat" {
|
|
||||||
return Err("backgroundMode 必须是 complex 或 flat".to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(color) = arguments.get("screenColor") {
|
|
||||||
let color = color
|
|
||||||
.as_str()
|
|
||||||
.ok_or_else(|| "screenColor 必须是 auto 或 #RRGGBB".to_string())?;
|
|
||||||
let valid_hex = color.len() == 7
|
|
||||||
&& color.starts_with('#')
|
|
||||||
&& color[1..]
|
|
||||||
.chars()
|
|
||||||
.all(|character| character.is_ascii_hexdigit());
|
|
||||||
if color != "auto" && !valid_hex {
|
|
||||||
return Err("screenColor 必须是 auto 或 #RRGGBB".to_string());
|
|
||||||
}
|
|
||||||
if arguments.get("backgroundMode").and_then(Value::as_str) != Some("flat") {
|
|
||||||
return Err("complex 模式不能传 screenColor".to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1891,51 +1849,6 @@ pub(crate) fn stop_game_creator_external_mcp() -> Result<(), String> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn remove_background_arguments_enforce_mode_color_contract() {
|
|
||||||
for fields in [
|
|
||||||
json!({}),
|
|
||||||
json!({"backgroundMode":"complex"}),
|
|
||||||
json!({"backgroundMode":"flat"}),
|
|
||||||
json!({"backgroundMode":"flat","screenColor":"auto"}),
|
|
||||||
json!({"backgroundMode":"flat","screenColor":"#Ab12EF"}),
|
|
||||||
] {
|
|
||||||
let mut arguments = json!({"sourceLocalAssetId":"asset-1","assetName":"透明图"});
|
|
||||||
arguments
|
|
||||||
.as_object_mut()
|
|
||||||
.unwrap()
|
|
||||||
.extend(fields.as_object().unwrap().clone());
|
|
||||||
assert!(
|
|
||||||
validate_remove_background_arguments(&arguments).is_ok(),
|
|
||||||
"{fields}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
for fields in [
|
|
||||||
json!({"screenColor":"auto"}),
|
|
||||||
json!({"backgroundMode":"complex","screenColor":"auto"}),
|
|
||||||
json!({"backgroundMode":"flat","screenColor":""}),
|
|
||||||
json!({"backgroundMode":"flat","screenColor":" auto "}),
|
|
||||||
json!({"backgroundMode":"flat","screenColor":"AUTO"}),
|
|
||||||
json!({"backgroundMode":"flat","screenColor":"#GGGGGG"}),
|
|
||||||
json!({"backgroundMode":"flat","screenColor":null}),
|
|
||||||
json!({"backgroundMode":"flat","screenColor":12}),
|
|
||||||
json!({"backgroundMode":""}),
|
|
||||||
json!({"backgroundMode":"FLAT"}),
|
|
||||||
json!({"backgroundMode":" flat "}),
|
|
||||||
json!({"backgroundMode":null}),
|
|
||||||
] {
|
|
||||||
let mut arguments = json!({"sourceLocalAssetId":"asset-1","assetName":"透明图"});
|
|
||||||
arguments
|
|
||||||
.as_object_mut()
|
|
||||||
.unwrap()
|
|
||||||
.extend(fields.as_object().unwrap().clone());
|
|
||||||
assert!(
|
|
||||||
validate_remove_background_arguments(&arguments).is_err(),
|
|
||||||
"{fields}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(all(windows, feature = "cocos-editor-execute"))]
|
#[cfg(all(windows, feature = "cocos-editor-execute"))]
|
||||||
#[test]
|
#[test]
|
||||||
fn builtin_mcp_process_probe() {
|
fn builtin_mcp_process_probe() {
|
||||||
|
|||||||
@@ -5035,7 +5035,21 @@ pub(crate) fn read_local_project_text_preview_at(
|
|||||||
return Err("只能读取当前项目已登记的文档资源".to_string());
|
return Err("只能读取当前项目已登记的文档资源".to_string());
|
||||||
}
|
}
|
||||||
cancellation.check()?;
|
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]
|
#[tauri::command]
|
||||||
@@ -5401,19 +5415,29 @@ pub(crate) async fn read_direct_project_history_slice(
|
|||||||
project_path: String,
|
project_path: String,
|
||||||
before_item_id: Option<String>,
|
before_item_id: Option<String>,
|
||||||
limit: Option<usize>,
|
limit: Option<usize>,
|
||||||
|
messages_only: Option<bool>,
|
||||||
) -> Result<DirectThreadHistorySlice, String> {
|
) -> Result<DirectThreadHistorySlice, String> {
|
||||||
tauri::async_runtime::spawn_blocking(move || {
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
let root = Path::new(project_path.trim());
|
let root = Path::new(project_path.trim());
|
||||||
enforce_project_permission_policy(root, "conversation.read")?;
|
enforce_project_permission_policy(root, "conversation.read")?;
|
||||||
let (items, has_more, item_timestamps) = read_direct_project_history_items_slice_at(
|
let read_slice = if messages_only.unwrap_or(false) {
|
||||||
root,
|
read_direct_project_chat_items_slice_at
|
||||||
before_item_id.as_deref(),
|
} else {
|
||||||
limit.unwrap_or(20),
|
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 {
|
Ok(DirectThreadHistorySlice {
|
||||||
items,
|
items,
|
||||||
has_more,
|
has_more,
|
||||||
item_timestamps,
|
item_timestamps,
|
||||||
|
oldest_item_id,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ pub(crate) struct LocalProjectTextPreview {
|
|||||||
pub(crate) media_type: String,
|
pub(crate) media_type: String,
|
||||||
pub(crate) byte_len: u64,
|
pub(crate) byte_len: u64,
|
||||||
pub(crate) content: String,
|
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)]
|
#[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(),
|
media_type: media_type.to_string(),
|
||||||
byte_len: content.len() as u64,
|
byte_len: content.len() as u64,
|
||||||
content,
|
content,
|
||||||
|
ui_design_asset_id: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ pub(crate) fn load_ui_design_state_at(
|
|||||||
let root = Path::new(input.project_path.trim());
|
let root = Path::new(input.project_path.trim());
|
||||||
let expected_project_id = required_identifier(&input.expected_project_id, "expectedProjectId")?;
|
let expected_project_id = required_identifier(&input.expected_project_id, "expectedProjectId")?;
|
||||||
let asset_id = required_identifier(&input.asset_id, "assetId")?;
|
let asset_id = required_identifier(&input.asset_id, "assetId")?;
|
||||||
let asset = ui_design_asset(root, &expected_project_id, &asset_id)?;
|
let asset = registered_json_asset(root, &expected_project_id, &asset_id)?;
|
||||||
let document =
|
let document =
|
||||||
read_ui_design_document(root, &asset.local_path, &expected_project_id, &asset_id)?;
|
read_ui_design_document(root, &asset.local_path, &expected_project_id, &asset_id)?;
|
||||||
validate_document(&document, &expected_project_id, &asset_id)?;
|
validate_document(&document, &expected_project_id, &asset_id)?;
|
||||||
@@ -175,7 +175,7 @@ pub(crate) fn generate_ui_design_code_at(
|
|||||||
let expected_project_id = required_identifier(&input.expected_project_id, "expectedProjectId")?;
|
let expected_project_id = required_identifier(&input.expected_project_id, "expectedProjectId")?;
|
||||||
let asset_id = required_identifier(&input.asset_id, "assetId")?;
|
let asset_id = required_identifier(&input.asset_id, "assetId")?;
|
||||||
let _lock = acquire_project_write_lock(root, "ui_design.code_generate")?;
|
let _lock = acquire_project_write_lock(root, "ui_design.code_generate")?;
|
||||||
let asset = ui_design_asset(root, &expected_project_id, &asset_id)?;
|
let asset = registered_json_asset(root, &expected_project_id, &asset_id)?;
|
||||||
let document =
|
let document =
|
||||||
read_ui_design_document_locked(root, &asset.local_path, &expected_project_id, &asset_id)?;
|
read_ui_design_document_locked(root, &asset.local_path, &expected_project_id, &asset_id)?;
|
||||||
let (content, tree_exports, node_count) = render_ui_design_state_js(&document.state)?;
|
let (content, tree_exports, node_count) = render_ui_design_state_js(&document.state)?;
|
||||||
@@ -225,9 +225,9 @@ pub(crate) fn save_ui_design_state_at(
|
|||||||
let expected_project_id = required_identifier(&input.expected_project_id, "expectedProjectId")?;
|
let expected_project_id = required_identifier(&input.expected_project_id, "expectedProjectId")?;
|
||||||
let asset_id = required_identifier(&input.asset_id, "assetId")?;
|
let asset_id = required_identifier(&input.asset_id, "assetId")?;
|
||||||
|
|
||||||
let preflight_asset = ui_design_asset(root, &expected_project_id, &asset_id)?;
|
let preflight_asset = registered_json_asset(root, &expected_project_id, &asset_id)?;
|
||||||
let _lock = acquire_project_write_lock(root, "ui_design.state_save")?;
|
let _lock = acquire_project_write_lock(root, "ui_design.state_save")?;
|
||||||
let asset = ui_design_asset(root, &expected_project_id, &asset_id)?;
|
let asset = registered_json_asset(root, &expected_project_id, &asset_id)?;
|
||||||
if asset.local_path != preflight_asset.local_path {
|
if asset.local_path != preflight_asset.local_path {
|
||||||
return Err("UI 设计资源在保存锁获取期间发生变化,请重试".to_string());
|
return Err("UI 设计资源在保存锁获取期间发生变化,请重试".to_string());
|
||||||
}
|
}
|
||||||
@@ -311,6 +311,19 @@ fn ui_design_asset(
|
|||||||
root: &Path,
|
root: &Path,
|
||||||
expected_project_id: &str,
|
expected_project_id: &str,
|
||||||
asset_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" || asset.media_type != "application/json" {
|
||||||
|
return Err("目标资源不是 UI 设计 JSON 资产".to_string());
|
||||||
|
}
|
||||||
|
Ok(asset)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn registered_json_asset(
|
||||||
|
root: &Path,
|
||||||
|
expected_project_id: &str,
|
||||||
|
asset_id: &str,
|
||||||
) -> Result<GameCreationAppAssetManifestEntry, String> {
|
) -> Result<GameCreationAppAssetManifestEntry, String> {
|
||||||
let manifest = read_existing_manifest_for_project(root)?;
|
let manifest = read_existing_manifest_for_project(root)?;
|
||||||
if manifest.project_id != expected_project_id {
|
if manifest.project_id != expected_project_id {
|
||||||
@@ -321,8 +334,10 @@ fn ui_design_asset(
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.find(|asset| asset.id == asset_id)
|
.find(|asset| asset.id == asset_id)
|
||||||
.ok_or_else(|| "UI 设计资源不存在".to_string())?;
|
.ok_or_else(|| "UI 设计资源不存在".to_string())?;
|
||||||
if asset.kind != "UI" || asset.media_type != "application/json" {
|
if !asset.local_path.to_ascii_lowercase().ends_with(".json")
|
||||||
return Err("目标资源不是 UI 设计 JSON 资产".to_string());
|
|| !is_supported_project_text_resource(&asset.local_path, &asset.media_type)
|
||||||
|
{
|
||||||
|
return Err("目标资源不是已登记的 JSON 资产".to_string());
|
||||||
}
|
}
|
||||||
normalize_relative_path(&asset.local_path)?;
|
normalize_relative_path(&asset.local_path)?;
|
||||||
Ok(asset)
|
Ok(asset)
|
||||||
@@ -393,10 +408,27 @@ fn read_ui_design_document_path(path: &Path) -> Result<PersistedUiDesignState, S
|
|||||||
"UI 设计 State 超过 {UI_DESIGN_STATE_MAX_BYTES} 字节上限"
|
"UI 设计 State 超过 {UI_DESIGN_STATE_MAX_BYTES} 字节上限"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let value: serde_json::Value = serde_json::from_slice(&bytes)
|
parse_ui_design_document(&bytes)
|
||||||
.map_err(|error| format!("解析 UI 设计 State 失败:{}: {error}", path.display()))?;
|
.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())
|
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 =
|
let canonical: serde_json::Value =
|
||||||
serde_json::from_slice(&serialize_ui_design_document(&document)?)
|
serde_json::from_slice(&serialize_ui_design_document(&document)?)
|
||||||
.map_err(|error| format!("序列化 UI 设计 State 契约失败:{error}"))?;
|
.map_err(|error| format!("序列化 UI 设计 State 契约失败:{error}"))?;
|
||||||
@@ -847,6 +879,170 @@ 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_and_editor_accept_valid_state_without_rewriting_asset_kind() {
|
||||||
|
for kind in ["UI", "ui", "ui-design", "document"] {
|
||||||
|
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.to_string();
|
||||||
|
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(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(loaded.revision, 0);
|
||||||
|
let saved = save_ui_design_state_at(input(
|
||||||
|
directory.path(),
|
||||||
|
&asset_id,
|
||||||
|
0,
|
||||||
|
state_with_unavailable_image("assets/page.png"),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
saved,
|
||||||
|
SaveUiDesignStateResult::Saved { revision: 1, .. }
|
||||||
|
));
|
||||||
|
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 = "document".to_string();
|
||||||
|
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 {
|
fn state_with_unavailable_image(path: &str) -> State {
|
||||||
serde_json::from_value(serde_json::json!({
|
serde_json::from_value(serde_json::json!({
|
||||||
"ui_trees": [],
|
"ui_trees": [],
|
||||||
|
|||||||
@@ -239,10 +239,11 @@ import { DeveloperProjectPanels } from './features/project-workspace/DeveloperPr
|
|||||||
import { DeveloperRuntimePanels } from './features/project-workspace/DeveloperRuntimePanels';
|
import { DeveloperRuntimePanels } from './features/project-workspace/DeveloperRuntimePanels';
|
||||||
import {
|
import {
|
||||||
type DirectThreadConsumeResult,
|
type DirectThreadConsumeResult,
|
||||||
directThreadHistoryItemsToMessages,
|
directThreadHistoryPage,
|
||||||
type DirectThreadHistorySlice,
|
type DirectThreadHistorySlice,
|
||||||
type DirectThreadSubscriptionBootstrap,
|
type DirectThreadSubscriptionBootstrap,
|
||||||
isDirectTurnInProgress,
|
isDirectTurnInProgress,
|
||||||
|
prependDirectHistoryMessages,
|
||||||
} from './features/project-workspace/directThreadEvents';
|
} from './features/project-workspace/directThreadEvents';
|
||||||
import { normalizeDirectTimestamp } from './features/project-workspace/directTurnPresentation';
|
import { normalizeDirectTimestamp } from './features/project-workspace/directTurnPresentation';
|
||||||
import type { DirectCodexUserContentPart } from './features/project-workspace/generated';
|
import type { DirectCodexUserContentPart } from './features/project-workspace/generated';
|
||||||
@@ -1958,7 +1959,7 @@ export function App({
|
|||||||
);
|
);
|
||||||
const [directHistoryHasMore, setDirectHistoryHasMore] = useState(false);
|
const [directHistoryHasMore, setDirectHistoryHasMore] = useState(false);
|
||||||
const directHistoryOldestItemIdRef = useRef<string | null>(null);
|
const directHistoryOldestItemIdRef = useRef<string | null>(null);
|
||||||
const directHistoryLoadingRef = useRef(false);
|
const directHistoryLoadingRef = useRef<number | null>(null);
|
||||||
const [pendingCommand, setPendingCommand] = useState<PendingCommand | null>(
|
const [pendingCommand, setPendingCommand] = useState<PendingCommand | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
@@ -2151,6 +2152,7 @@ export function App({
|
|||||||
|
|
||||||
function resetProjectSupervisorState() {
|
function resetProjectSupervisorState() {
|
||||||
projectSupervisorHistoryLoadVersionRef.current += 1;
|
projectSupervisorHistoryLoadVersionRef.current += 1;
|
||||||
|
directHistoryLoadingRef.current = null;
|
||||||
projectSupervisorRuntimeResumeProjectPathRef.current = null;
|
projectSupervisorRuntimeResumeProjectPathRef.current = null;
|
||||||
projectSupervisorSessionIdRef.current = null;
|
projectSupervisorSessionIdRef.current = null;
|
||||||
projectSupervisorRuntimeRef.current = null;
|
projectSupervisorRuntimeRef.current = null;
|
||||||
@@ -4009,6 +4011,8 @@ export function App({
|
|||||||
}
|
}
|
||||||
const loadVersion = projectSupervisorHistoryLoadVersionRef.current + 1;
|
const loadVersion = projectSupervisorHistoryLoadVersionRef.current + 1;
|
||||||
projectSupervisorHistoryLoadVersionRef.current = loadVersion;
|
projectSupervisorHistoryLoadVersionRef.current = loadVersion;
|
||||||
|
if (directCodexProductRuntime)
|
||||||
|
directHistoryLoadingRef.current = loadVersion;
|
||||||
try {
|
try {
|
||||||
// V2 projects do not have a Supervisor run or legacy conversation. Probe the
|
// 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
|
// V2 authority first; a missing V2 session returns null and preserves the
|
||||||
@@ -4105,6 +4109,7 @@ export function App({
|
|||||||
: await readProjectSupervisorActiveSession(invoke, nextProjectPath);
|
: await readProjectSupervisorActiveSession(invoke, nextProjectPath);
|
||||||
let runtimeError = '';
|
let runtimeError = '';
|
||||||
let loadedDirectHistoryHasMore = false;
|
let loadedDirectHistoryHasMore = false;
|
||||||
|
let loadedDirectHistoryCursor: string | null = null;
|
||||||
const projectConversation = directCodexProductRuntime
|
const projectConversation = directCodexProductRuntime
|
||||||
? (() => {
|
? (() => {
|
||||||
return invoke<DirectThreadHistorySlice>(
|
return invoke<DirectThreadHistorySlice>(
|
||||||
@@ -4112,16 +4117,16 @@ export function App({
|
|||||||
{
|
{
|
||||||
projectPath: nextProjectPath,
|
projectPath: nextProjectPath,
|
||||||
limit: CONVERSATION_INITIAL_VISIBLE_COUNT,
|
limit: CONVERSATION_INITIAL_VISIBLE_COUNT,
|
||||||
|
messagesOnly: true,
|
||||||
},
|
},
|
||||||
).then((slice) => {
|
).then((slice) => {
|
||||||
loadedDirectHistoryHasMore = slice.hasMore;
|
const page = directThreadHistoryPage(slice);
|
||||||
|
loadedDirectHistoryHasMore = page.hasMore;
|
||||||
|
loadedDirectHistoryCursor = page.cursor;
|
||||||
return {
|
return {
|
||||||
path: nextProjectPath,
|
path: nextProjectPath,
|
||||||
agentId: null,
|
agentId: null,
|
||||||
messages: directThreadHistoryItemsToMessages(
|
messages: page.messages,
|
||||||
slice.items,
|
|
||||||
slice.itemTimestamps,
|
|
||||||
),
|
|
||||||
} satisfies LocalConversationResult;
|
} satisfies LocalConversationResult;
|
||||||
});
|
});
|
||||||
})()
|
})()
|
||||||
@@ -4218,9 +4223,7 @@ export function App({
|
|||||||
setProjectSupervisorRuntimeError(runtimeError || resumeError);
|
setProjectSupervisorRuntimeError(runtimeError || resumeError);
|
||||||
if (directCodexProductRuntime) {
|
if (directCodexProductRuntime) {
|
||||||
setDirectHistoryHasMore(loadedDirectHistoryHasMore);
|
setDirectHistoryHasMore(loadedDirectHistoryHasMore);
|
||||||
directHistoryOldestItemIdRef.current =
|
directHistoryOldestItemIdRef.current = loadedDirectHistoryCursor;
|
||||||
conversationMessages.find((message) => message.messageId)
|
|
||||||
?.messageId ?? null;
|
|
||||||
}
|
}
|
||||||
setMessages((current) => {
|
setMessages((current) => {
|
||||||
// replace 分支同样不能丢掉尚未落盘的运行时消息(初始需求)。
|
// replace 分支同样不能丢掉尚未落盘的运行时消息(初始需求)。
|
||||||
@@ -4281,6 +4284,10 @@ export function App({
|
|||||||
: workspaceStatus,
|
: workspaceStatus,
|
||||||
);
|
);
|
||||||
// Keep the default greeting when history is missing or blocked.
|
// 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([]);
|
setAgentRunHistoryFiles([]);
|
||||||
setAgentRuntimeById({});
|
setAgentRuntimeById({});
|
||||||
setMessages(conversationMessages);
|
setMessages(conversationMessages);
|
||||||
|
// 只有新项目确实打开后才丢弃旧分页位置;打开失败时旧会话仍可继续翻页。
|
||||||
|
directHistoryOldestItemIdRef.current = null;
|
||||||
|
setDirectHistoryHasMore(false);
|
||||||
setConversationVisibleCount(CONVERSATION_INITIAL_VISIBLE_COUNT);
|
setConversationVisibleCount(CONVERSATION_INITIAL_VISIBLE_COUNT);
|
||||||
savedConversationProjectPathRef.current = openedProject.projectPath;
|
savedConversationProjectPathRef.current = openedProject.projectPath;
|
||||||
savedConversationCountRef.current = conversationMessages.length;
|
savedConversationCountRef.current = conversationMessages.length;
|
||||||
@@ -12480,27 +12490,38 @@ export function App({
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
async function showEarlierConversationMessages() {
|
async function showEarlierConversationMessages() {
|
||||||
|
if (hiddenConversationCount > 0) {
|
||||||
|
setConversationVisibleCount((current) =>
|
||||||
|
Math.min(messages.length, current + CONVERSATION_VISIBLE_STEP),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (directCodexProductRuntime && directHistoryHasMore) {
|
if (directCodexProductRuntime && directHistoryHasMore) {
|
||||||
const invoke = resolveTauriInvoke();
|
const invoke = resolveTauriInvoke();
|
||||||
const projectPath = localProject?.projectPath;
|
const projectPath = localProject?.projectPath;
|
||||||
if (invoke && projectPath && !directHistoryLoadingRef.current) {
|
if (invoke && projectPath && directHistoryLoadingRef.current === null) {
|
||||||
directHistoryLoadingRef.current = true;
|
const loadVersion = projectSupervisorHistoryLoadVersionRef.current;
|
||||||
|
const beforeItemId = directHistoryOldestItemIdRef.current;
|
||||||
|
directHistoryLoadingRef.current = loadVersion;
|
||||||
|
const isCurrentLoad = () =>
|
||||||
|
manifestRefreshMountedRef.current &&
|
||||||
|
localProjectPathRef.current === projectPath &&
|
||||||
|
projectSupervisorHistoryLoadVersionRef.current === loadVersion;
|
||||||
try {
|
try {
|
||||||
const slice = await invoke<DirectThreadHistorySlice>(
|
const slice = await invoke<DirectThreadHistorySlice>(
|
||||||
'read_direct_project_history_slice',
|
'read_direct_project_history_slice',
|
||||||
{
|
{
|
||||||
projectPath,
|
projectPath,
|
||||||
beforeItemId: directHistoryOldestItemIdRef.current,
|
beforeItemId,
|
||||||
limit: CONVERSATION_VISIBLE_STEP,
|
limit: CONVERSATION_VISIBLE_STEP,
|
||||||
|
messagesOnly: true,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (localProjectPathRef.current !== projectPath) {
|
if (!isCurrentLoad()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const older = directThreadHistoryItemsToMessages(
|
const page = directThreadHistoryPage(slice, beforeItemId);
|
||||||
slice.items,
|
const older = page.messages.map((message) => ({
|
||||||
slice.itemTimestamps,
|
|
||||||
).map((message) => ({
|
|
||||||
role:
|
role:
|
||||||
message.role === 'user'
|
message.role === 'user'
|
||||||
? ('user' as const)
|
? ('user' as const)
|
||||||
@@ -12510,18 +12531,21 @@ export function App({
|
|||||||
messageId: message.messageId,
|
messageId: message.messageId,
|
||||||
updatedAt: message.updatedAt,
|
updatedAt: message.updatedAt,
|
||||||
}));
|
}));
|
||||||
setMessages((current) => [...older, ...current]);
|
setMessages((current) =>
|
||||||
|
prependDirectHistoryMessages(current, older),
|
||||||
|
);
|
||||||
setConversationVisibleCount((current) => current + older.length);
|
setConversationVisibleCount((current) => current + older.length);
|
||||||
setDirectHistoryHasMore(slice.hasMore);
|
setDirectHistoryHasMore(page.hasMore);
|
||||||
directHistoryOldestItemIdRef.current =
|
directHistoryOldestItemIdRef.current = page.cursor;
|
||||||
older.find((message) => message.messageId)?.messageId ??
|
|
||||||
directHistoryOldestItemIdRef.current;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (!isCurrentLoad()) return;
|
||||||
setWorkspaceStatus(
|
setWorkspaceStatus(
|
||||||
`读取更早的对话历史失败:${error instanceof Error ? error.message : String(error)}`,
|
`读取更早的对话历史失败:${error instanceof Error ? error.message : String(error)}`,
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
directHistoryLoadingRef.current = false;
|
if (directHistoryLoadingRef.current === loadVersion) {
|
||||||
|
directHistoryLoadingRef.current = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -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 { createPortal } from 'react-dom';
|
||||||
|
|
||||||
import { launcherNotifications } from '../../app/constants';
|
import { launcherNotifications } from '../../app/constants';
|
||||||
@@ -201,12 +208,18 @@ export function WorkspaceLauncherShell({
|
|||||||
*/
|
*/
|
||||||
const manifestMergeNoticeScopeRef = useRef<string | null>(null);
|
const manifestMergeNoticeScopeRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
// 打开项目由创建流程提供,其函数引用随渲染变化;标题栏只持有稳定的转发入口,
|
||||||
|
// 否则发布 Context 会再次触发工作台 effect,形成发布/清理循环。
|
||||||
|
const openProjectRef = useRef(openProject);
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
openProjectRef.current = openProject;
|
||||||
|
}, [openProject]);
|
||||||
const openActiveProject = useCallback(
|
const openActiveProject = useCallback(
|
||||||
(nextProjectPath: string) => {
|
(nextProjectPath: string) => {
|
||||||
setProjectPath(nextProjectPath);
|
setProjectPath(nextProjectPath);
|
||||||
void openProject(nextProjectPath, 'open');
|
void openProjectRef.current(nextProjectPath, 'open');
|
||||||
},
|
},
|
||||||
[openProject, setProjectPath],
|
[setProjectPath],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -216,7 +229,6 @@ export function WorkspaceLauncherShell({
|
|||||||
readFailed: snapshotReadFailed,
|
readFailed: snapshotReadFailed,
|
||||||
onOpenProject: openActiveProject,
|
onOpenProject: openActiveProject,
|
||||||
});
|
});
|
||||||
return () => setActiveProjectRuns(null);
|
|
||||||
}, [
|
}, [
|
||||||
activeTurns,
|
activeTurns,
|
||||||
currentProjectContext?.projectPath,
|
currentProjectContext?.projectPath,
|
||||||
@@ -224,6 +236,7 @@ export function WorkspaceLauncherShell({
|
|||||||
setActiveProjectRuns,
|
setActiveProjectRuns,
|
||||||
snapshotReadFailed,
|
snapshotReadFailed,
|
||||||
]);
|
]);
|
||||||
|
useEffect(() => () => setActiveProjectRuns(null), [setActiveProjectRuns]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const projectPath = currentProjectContext?.projectPath ?? null;
|
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 = {
|
export type DirectThreadRawEvent = {
|
||||||
seq: number;
|
seq: number;
|
||||||
@@ -22,8 +25,54 @@ export type DirectThreadHistorySlice = {
|
|||||||
items: unknown[];
|
items: unknown[];
|
||||||
hasMore: boolean;
|
hasMore: boolean;
|
||||||
itemTimestamps?: Record<string, number>;
|
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(
|
export function directThreadHistoryItemsToMessages(
|
||||||
items: unknown[],
|
items: unknown[],
|
||||||
itemTimestamps: Readonly<Record<string, number>> = {},
|
itemTimestamps: Readonly<Record<string, number>> = {},
|
||||||
|
|||||||
@@ -37,6 +37,23 @@ export function isResourceCanvasInteractionTarget(
|
|||||||
return Boolean(target?.closest(RESOURCE_CANVAS_INTERACTION_SELECTOR));
|
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',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 画布浮层里有自己滚动区的那几个:落在它们里面的滚轮归浮层,画布不得消费。
|
* 画布浮层里有自己滚动区的那几个:落在它们里面的滚轮归浮层,画布不得消费。
|
||||||
*
|
*
|
||||||
|
|||||||
+7
-3
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
isProjectResourceJson,
|
||||||
projectResourceCardPreviewKind,
|
projectResourceCardPreviewKind,
|
||||||
projectResourcePathExtension,
|
projectResourcePathExtension,
|
||||||
} from '../../view/project-development/resourceCardPreviewModel';
|
} from '../../view/project-development/resourceCardPreviewModel';
|
||||||
@@ -65,11 +66,14 @@ export function resourceDocumentPreviewMarkdown(
|
|||||||
resource: ProjectResource,
|
resource: ProjectResource,
|
||||||
content: string,
|
content: string,
|
||||||
) {
|
) {
|
||||||
if (projectResourceCardPreviewKind(resource) !== 'code') {
|
const isJson = isProjectResourceJson(resource);
|
||||||
|
if (projectResourceCardPreviewKind(resource) !== 'code' && !isJson) {
|
||||||
return content;
|
return content;
|
||||||
}
|
}
|
||||||
const language =
|
const language = isJson
|
||||||
CODE_LANGUAGES[projectResourcePathExtension(resource.path) ?? ''] ?? 'text';
|
? 'json'
|
||||||
|
: (CODE_LANGUAGES[projectResourcePathExtension(resource.path) ?? ''] ??
|
||||||
|
'text');
|
||||||
// 围栏长于源码里的任意反引号串,代码生成模板中的 Markdown 不能提前闭合代码块。
|
// 围栏长于源码里的任意反引号串,代码生成模板中的 Markdown 不能提前闭合代码块。
|
||||||
const longestRun = (content.match(/`+/g) ?? []).reduce(
|
const longestRun = (content.match(/`+/g) ?? []).reduce(
|
||||||
(length, run) => Math.max(length, run.length),
|
(length, run) => Math.max(length, run.length),
|
||||||
|
|||||||
@@ -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 { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
projectResourceCardPreviewKind,
|
projectResourceCardPreviewKind,
|
||||||
type ProjectResourceCardPreviewState,
|
type ProjectResourceCardPreviewState,
|
||||||
projectResourceCardPreviewVariant,
|
projectResourceCardPreviewVariant,
|
||||||
|
projectResourceJsonPresentation,
|
||||||
} from './resourceCardPreviewModel';
|
} from './resourceCardPreviewModel';
|
||||||
import type { ProjectResource } from './resourceProjectionModel';
|
import type { ProjectResource } from './resourceProjectionModel';
|
||||||
|
|
||||||
@@ -90,6 +97,16 @@ export function ResourcePreviewMedia({
|
|||||||
const sourceUrl =
|
const sourceUrl =
|
||||||
preview.status === 'loaded' ? (preview.preview.sourceUrl ?? null) : null;
|
preview.status === 'loaded' ? (preview.preview.sourceUrl ?? null) : null;
|
||||||
const visual = (() => {
|
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')) {
|
if (sourceUrl && (kind === 'raster-image' || kind === 'media-image')) {
|
||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ import { ResourceCanvasBottomToolbarView } from '../../features/resource-canvas/
|
|||||||
import {
|
import {
|
||||||
canDismissResourceCanvasQuickEdit,
|
canDismissResourceCanvasQuickEdit,
|
||||||
isResourceCanvasInteractionTarget,
|
isResourceCanvasInteractionTarget,
|
||||||
|
isResourceCanvasPanTarget,
|
||||||
isResourceCanvasWheelOverlayTarget,
|
isResourceCanvasWheelOverlayTarget,
|
||||||
resolveResourceCanvasFloatingPanelDismissOpen,
|
resolveResourceCanvasFloatingPanelDismissOpen,
|
||||||
resolveResourceCanvasFloatingPanelOpen,
|
resolveResourceCanvasFloatingPanelOpen,
|
||||||
@@ -297,6 +298,7 @@ import {
|
|||||||
projectResourceCardPreviewVariant,
|
projectResourceCardPreviewVariant,
|
||||||
projectResourceCodeTypeLabel,
|
projectResourceCodeTypeLabel,
|
||||||
projectResourceDocumentPreviewText,
|
projectResourceDocumentPreviewText,
|
||||||
|
projectResourceJsonPresentation,
|
||||||
} from './resourceCardPreviewModel';
|
} from './resourceCardPreviewModel';
|
||||||
import { ResourceClassificationPanel } from './ResourceClassificationPanel';
|
import { ResourceClassificationPanel } from './ResourceClassificationPanel';
|
||||||
import {
|
import {
|
||||||
@@ -766,8 +768,8 @@ const ResourceCard = memo(function ResourceCard({
|
|||||||
*
|
*
|
||||||
* 取值口径与画布栏目完全同源:`resource.category` 就是这张卡在画布上所属的栏目,
|
* 取值口径与画布栏目完全同源:`resource.category` 就是这张卡在画布上所属的栏目,
|
||||||
* `categoryLabels` 是栏目与筛选共用的同一份中文展示名(`resourceReferenceCategoryLabel`
|
* `categoryLabels` 是栏目与筛选共用的同一份中文展示名(`resourceReferenceCategoryLabel`
|
||||||
* 加末尾「项目版本」),所以角标文案恒等于它所在的栏目名 —— 不再是"图片 / 视频 / 文档"
|
* 加末尾「项目版本」)。JSON 单独显示原生识别后的 UI 设计 / JSON,
|
||||||
* 那套媒体口径。媒体类型仍由卡面视觉(图片 / 视频 / 音频 / 文档摘要)表达,信息没有丢失。
|
* 但不改变栏目、筛选或 manifest 分类;其它资源继续使用栏目标签。
|
||||||
*/
|
*/
|
||||||
const resourceCategoryLabel = categoryLabels[resource.category];
|
const resourceCategoryLabel = categoryLabels[resource.category];
|
||||||
const isMedia = kind === 'video' || kind === 'audio';
|
const isMedia = kind === 'video' || kind === 'audio';
|
||||||
@@ -781,9 +783,17 @@ const ResourceCard = memo(function ResourceCard({
|
|||||||
* 等读完再决定画什么只会把"丑预览"和多余 IO 一起留在链路上。
|
* 等读完再决定画什么只会把"丑预览"和多余 IO 一起留在链路上。
|
||||||
*/
|
*/
|
||||||
const previewVariant = projectResourceCardPreviewVariant(resource);
|
const previewVariant = projectResourceCardPreviewVariant(resource);
|
||||||
|
const jsonPresentation = projectResourceJsonPresentation(resource, preview);
|
||||||
|
const cardTypeLabel =
|
||||||
|
jsonPresentation === 'ui-design'
|
||||||
|
? 'UI 设计'
|
||||||
|
: jsonPresentation === 'json'
|
||||||
|
? 'JSON'
|
||||||
|
: resourceCategoryLabel;
|
||||||
/** 代码卡的类型标签(`.tsx` → `TSX`);不是代码卡时为 `null`。 */
|
/** 代码卡的类型标签(`.tsx` → `TSX`);不是代码卡时为 `null`。 */
|
||||||
const codeTypeLabel = projectResourceCodeTypeLabel(resource.path);
|
const codeTypeLabel = projectResourceCodeTypeLabel(resource.path);
|
||||||
const documentPreview =
|
const documentPreview =
|
||||||
|
!jsonPresentation &&
|
||||||
preview.status === 'loaded' &&
|
preview.status === 'loaded' &&
|
||||||
preview.preview.content !== undefined &&
|
preview.preview.content !== undefined &&
|
||||||
previewVariant !== null &&
|
previewVariant !== null &&
|
||||||
@@ -822,6 +832,20 @@ const ResourceCard = memo(function ResourceCard({
|
|||||||
}, [previewIdentity, sourceUrl]);
|
}, [previewIdentity, sourceUrl]);
|
||||||
|
|
||||||
const visual = (() => {
|
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) {
|
if ((kind === 'raster-image' || kind === 'media-image') && sourceUrl) {
|
||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
@@ -997,6 +1021,7 @@ const ResourceCard = memo(function ResourceCard({
|
|||||||
}`}
|
}`}
|
||||||
data-resource-card-id={resource.id}
|
data-resource-card-id={resource.id}
|
||||||
data-preview-kind={kind}
|
data-preview-kind={kind}
|
||||||
|
data-json-presentation={jsonPresentation ?? undefined}
|
||||||
// 占位分支对「还没读 / 读失败 / 压根不适用」给的是同一个图标、且卡面没有文字提示,
|
// 占位分支对「还没读 / 读失败 / 压根不适用」给的是同一个图标、且卡面没有文字提示,
|
||||||
// 现场无法区分「为什么没有图」。这里把预览状态与失败原因暴露到 DOM,
|
// 现场无法区分「为什么没有图」。这里把预览状态与失败原因暴露到 DOM,
|
||||||
// 让排障只需读一个属性,而不是去猜是调度没发请求还是原生拒绝了读取。
|
// 让排障只需读一个属性,而不是去猜是调度没发请求还是原生拒绝了读取。
|
||||||
@@ -1049,10 +1074,10 @@ const ResourceCard = memo(function ResourceCard({
|
|||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className="game-resource-card-type-badge"
|
className="game-resource-card-type-badge"
|
||||||
data-resource-type={resourceCategoryLabel}
|
data-resource-type={cardTypeLabel}
|
||||||
title={resourceCategoryLabel}
|
title={cardTypeLabel}
|
||||||
>
|
>
|
||||||
{resourceCategoryLabel}
|
{cardTypeLabel}
|
||||||
</span>
|
</span>
|
||||||
{lineage ? (
|
{lineage ? (
|
||||||
// 文字是给人看的关系,`data-resource-lineage` 是给端到端验收的稳定判据
|
// 文字是给人看的关系,`data-resource-lineage` 是给端到端验收的稳定判据
|
||||||
@@ -1342,6 +1367,7 @@ function ResourceBookScene({
|
|||||||
onPointerMove={onPointerMove}
|
onPointerMove={onPointerMove}
|
||||||
onPointerUp={onPointerUp}
|
onPointerUp={onPointerUp}
|
||||||
onPointerCancel={onPointerCancel}
|
onPointerCancel={onPointerCancel}
|
||||||
|
onLostPointerCapture={onPointerCancel}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="game-resource-book-scene-world"
|
className="game-resource-book-scene-world"
|
||||||
@@ -1485,6 +1511,7 @@ export default function ProjectDevelopmentView({
|
|||||||
});
|
});
|
||||||
const resourceBookMainPanRef = useRef<{
|
const resourceBookMainPanRef = useRef<{
|
||||||
pointerId: number;
|
pointerId: number;
|
||||||
|
captureTarget: HTMLElement;
|
||||||
startClientX: number;
|
startClientX: number;
|
||||||
startClientY: number;
|
startClientY: number;
|
||||||
startViewport: CanvasViewport;
|
startViewport: CanvasViewport;
|
||||||
@@ -1771,6 +1798,7 @@ export default function ProjectDevelopmentView({
|
|||||||
const resourceCanvasFitKeysRef = useRef<Set<string>>(new Set());
|
const resourceCanvasFitKeysRef = useRef<Set<string>>(new Set());
|
||||||
const resourceCanvasPanRef = useRef<{
|
const resourceCanvasPanRef = useRef<{
|
||||||
pointerId: number;
|
pointerId: number;
|
||||||
|
captureTarget: HTMLElement;
|
||||||
category: ResourceBookTarget;
|
category: ResourceBookTarget;
|
||||||
startClientX: number;
|
startClientX: number;
|
||||||
startClientY: number;
|
startClientY: number;
|
||||||
@@ -3363,15 +3391,14 @@ export default function ProjectDevelopmentView({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const cancelResourceCanvasPan = useCallback(() => {
|
const cancelResourceCanvasPan = useCallback(() => {
|
||||||
const pan = resourceCanvasPanRef.current;
|
const pans = [resourceCanvasPanRef.current, resourceBookMainPanRef.current];
|
||||||
if (!pan) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const canvas = resourceCanvasRef.current;
|
|
||||||
if (canvas?.hasPointerCapture?.(pan.pointerId)) {
|
|
||||||
canvas.releasePointerCapture?.(pan.pointerId);
|
|
||||||
}
|
|
||||||
resourceCanvasPanRef.current = null;
|
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(() => {
|
const cancelResourceCardDrag = useCallback(() => {
|
||||||
@@ -4749,6 +4776,8 @@ export default function ProjectDevelopmentView({
|
|||||||
};
|
};
|
||||||
const handleBlur = () => {
|
const handleBlur = () => {
|
||||||
resourceCanvasSpacePanRef.current = false;
|
resourceCanvasSpacePanRef.current = false;
|
||||||
|
cancelResourceCanvasPan();
|
||||||
|
setResourceCanvasMarquee(null);
|
||||||
};
|
};
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
window.addEventListener('keyup', handleKeyUp);
|
window.addEventListener('keyup', handleKeyUp);
|
||||||
@@ -4758,7 +4787,7 @@ export default function ProjectDevelopmentView({
|
|||||||
window.removeEventListener('keyup', handleKeyUp);
|
window.removeEventListener('keyup', handleKeyUp);
|
||||||
window.removeEventListener('blur', handleBlur);
|
window.removeEventListener('blur', handleBlur);
|
||||||
};
|
};
|
||||||
}, []);
|
}, [cancelResourceCanvasPan]);
|
||||||
|
|
||||||
const handleResourceBookWheel = useCallback(
|
const handleResourceBookWheel = useCallback(
|
||||||
(event: ReactWheelEvent<HTMLDivElement> | WheelEvent) => {
|
(event: ReactWheelEvent<HTMLDivElement> | WheelEvent) => {
|
||||||
@@ -4855,7 +4884,10 @@ export default function ProjectDevelopmentView({
|
|||||||
|
|
||||||
const handleResourceBookMainPointerDown = useCallback(
|
const handleResourceBookMainPointerDown = useCallback(
|
||||||
(event: ReactPointerEvent<HTMLDivElement>) => {
|
(event: ReactPointerEvent<HTMLDivElement>) => {
|
||||||
if (resourceBookView !== 'main' || event.button !== 0) {
|
if (
|
||||||
|
resourceBookView !== 'main' ||
|
||||||
|
(event.button !== 0 && event.button !== 2)
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const target = event.target as HTMLElement;
|
const target = event.target as HTMLElement;
|
||||||
@@ -4871,6 +4903,7 @@ export default function ProjectDevelopmentView({
|
|||||||
resourceBookTransitionControllerRef.current.settle();
|
resourceBookTransitionControllerRef.current.settle();
|
||||||
resourceBookMainPanRef.current = {
|
resourceBookMainPanRef.current = {
|
||||||
pointerId: event.pointerId,
|
pointerId: event.pointerId,
|
||||||
|
captureTarget: event.currentTarget,
|
||||||
startClientX: event.clientX,
|
startClientX: event.clientX,
|
||||||
startClientY: event.clientY,
|
startClientY: event.clientY,
|
||||||
startViewport: resourceBookMainViewportRef.current,
|
startViewport: resourceBookMainViewportRef.current,
|
||||||
@@ -5096,11 +5129,19 @@ export default function ProjectDevelopmentView({
|
|||||||
const canvasTarget = resourceBookOpensAllResources
|
const canvasTarget = resourceBookOpensAllResources
|
||||||
? RESOURCE_BOOK_ALL_TARGET
|
? RESOURCE_BOOK_ALL_TARGET
|
||||||
: activePageCategory;
|
: activePageCategory;
|
||||||
if (!canvasTarget || event.button > 1) {
|
if (!canvasTarget || event.button > 2) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const target = event.target as HTMLElement;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
// 点选态下空白处的左键不起框选、也不清画布焦点/选中:点选只认资源卡,
|
// 点选态下空白处的左键不起框选、也不清画布焦点/选中:点选只认资源卡,
|
||||||
@@ -5114,12 +5155,13 @@ export default function ProjectDevelopmentView({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
resourceBookTransitionControllerRef.current.settle();
|
resourceBookTransitionControllerRef.current.settle();
|
||||||
// 与美术画布一致:中键或按住空格是平移,左键在空白处是框选。
|
// 右键平移,保留中键/空格抓手;空白处左键继续框选。
|
||||||
if (event.button === 1 || resourceCanvasSpacePanRef.current) {
|
if (isPan) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.currentTarget.setPointerCapture(event.pointerId);
|
event.currentTarget.setPointerCapture(event.pointerId);
|
||||||
resourceCanvasPanRef.current = {
|
resourceCanvasPanRef.current = {
|
||||||
pointerId: event.pointerId,
|
pointerId: event.pointerId,
|
||||||
|
captureTarget: event.currentTarget,
|
||||||
category: canvasTarget,
|
category: canvasTarget,
|
||||||
startClientX: event.clientX,
|
startClientX: event.clientX,
|
||||||
startClientY: event.clientY,
|
startClientY: event.clientY,
|
||||||
@@ -5292,12 +5334,22 @@ export default function ProjectDevelopmentView({
|
|||||||
|
|
||||||
const openResourceUiEditor = useCallback(
|
const openResourceUiEditor = useCallback(
|
||||||
(resource: ProjectResource) => {
|
(resource: ProjectResource) => {
|
||||||
if (resource.subtype === 'ui-prototype') {
|
const identity = resourceCardPreviews.identityByResourceId.get(
|
||||||
|
resource.id,
|
||||||
|
);
|
||||||
|
const jsonPresentation = projectResourceJsonPresentation(
|
||||||
|
resource,
|
||||||
|
identity ? resourceCardPreviews.previews.get(identity) : null,
|
||||||
|
);
|
||||||
|
if (resource.subtype === 'ui-prototype' && jsonPresentation === null) {
|
||||||
void openUiDesignEditor(resource);
|
void openUiDesignEditor(resource);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (resource.manifestAssetId === null) {
|
if (
|
||||||
setResourceWorkbenchNotice('该 UI 资源缺少有效的正式资产身份');
|
resource.manifestAssetId === null ||
|
||||||
|
jsonPresentation !== 'ui-design'
|
||||||
|
) {
|
||||||
|
setResourceWorkbenchNotice('该资源尚未通过 UI 设计 JSON 校验');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
canvasOpenEpochRef.current += 1;
|
canvasOpenEpochRef.current += 1;
|
||||||
@@ -5318,7 +5370,13 @@ export default function ProjectDevelopmentView({
|
|||||||
: {}),
|
: {}),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[advanceFocusGeneration, manifest.assets, openUiDesignEditor],
|
[
|
||||||
|
advanceFocusGeneration,
|
||||||
|
manifest.assets,
|
||||||
|
openUiDesignEditor,
|
||||||
|
resourceCardPreviews.identityByResourceId,
|
||||||
|
resourceCardPreviews.previews,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -6202,8 +6260,7 @@ export default function ProjectDevelopmentView({
|
|||||||
setMode('run');
|
setMode('run');
|
||||||
}
|
}
|
||||||
|
|
||||||
const showRunUnavailableHint =
|
const showRunUnavailableHint = !runAvailable && !uiEditorRoute;
|
||||||
!runAvailable && selectedResourceIds.length === 0 && !uiEditorRoute;
|
|
||||||
|
|
||||||
const renderResourceBookCard = useCallback(
|
const renderResourceBookCard = useCallback(
|
||||||
(
|
(
|
||||||
@@ -7457,9 +7514,16 @@ export default function ProjectDevelopmentView({
|
|||||||
: null,
|
: null,
|
||||||
[manifest, selectedResource],
|
[manifest, selectedResource],
|
||||||
);
|
);
|
||||||
|
const selectedResourceJsonPresentation = selectedResource
|
||||||
|
? projectResourceJsonPresentation(
|
||||||
|
selectedResource,
|
||||||
|
selectedResourceCardPreview,
|
||||||
|
)
|
||||||
|
: null;
|
||||||
const selectedResourceOpensUiEditor =
|
const selectedResourceOpensUiEditor =
|
||||||
selectedResource?.subtype === 'UI' ||
|
selectedResourceJsonPresentation === 'ui-design' ||
|
||||||
selectedResource?.subtype === 'ui-prototype';
|
(selectedResourceJsonPresentation === null &&
|
||||||
|
selectedResource?.subtype === 'ui-prototype');
|
||||||
|
|
||||||
const selectedToolbarStyle = selectedResourceLayer
|
const selectedToolbarStyle = selectedResourceLayer
|
||||||
? resolveSelectedToolbarStyle({
|
? resolveSelectedToolbarStyle({
|
||||||
@@ -7847,10 +7911,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}`}
|
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-view={resourceBookState.view}
|
||||||
data-resource-book-transition={resourceBookState.phase}
|
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) => {
|
onPointerDownCapture={(event) => {
|
||||||
if (
|
if (
|
||||||
event.currentTarget.dataset.resourceBookTransition !==
|
event.currentTarget.dataset.resourceBookTransition !==
|
||||||
'idle' &&
|
'idle' &&
|
||||||
|
event.button === 0 &&
|
||||||
(event.target as Element).closest('.game-resource-card')
|
(event.target as Element).closest('.game-resource-card')
|
||||||
) {
|
) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -7924,7 +8002,8 @@ export default function ProjectDevelopmentView({
|
|||||||
<span>预览</span>
|
<span>预览</span>
|
||||||
</CanvasChromeButton>
|
</CanvasChromeButton>
|
||||||
) : null}
|
) : null}
|
||||||
{selectedResourceOpensUiEditor ? (
|
{selectedResource &&
|
||||||
|
selectedResourceOpensUiEditor ? (
|
||||||
<CanvasChromeButton
|
<CanvasChromeButton
|
||||||
className="image-canvas-editor__floating-toolbar-text-button"
|
className="image-canvas-editor__floating-toolbar-text-button"
|
||||||
label="UI 编辑器"
|
label="UI 编辑器"
|
||||||
@@ -8505,6 +8584,7 @@ export default function ProjectDevelopmentView({
|
|||||||
onPointerMove={handleResourceCanvasPointerMove}
|
onPointerMove={handleResourceCanvasPointerMove}
|
||||||
onPointerUp={stopResourceCanvasPan}
|
onPointerUp={stopResourceCanvasPan}
|
||||||
onPointerCancel={stopResourceCanvasPan}
|
onPointerCancel={stopResourceCanvasPan}
|
||||||
|
onLostPointerCapture={stopResourceCanvasPan}
|
||||||
>
|
>
|
||||||
{sortMode === 'dependency' &&
|
{sortMode === 'dependency' &&
|
||||||
dependencyRelationshipDescriptions.length > 0 ? (
|
dependencyRelationshipDescriptions.length > 0 ? (
|
||||||
|
|||||||
+29
-2
@@ -58,6 +58,8 @@ export type ProjectResourceCardPreviewPayload = {
|
|||||||
hasAlpha?: boolean;
|
hasAlpha?: boolean;
|
||||||
sourceUrl?: string;
|
sourceUrl?: string;
|
||||||
content?: string;
|
content?: string;
|
||||||
|
/** 原生侧完整校验过的 UI State 资产身份,不由前端解析 JSON 推断。 */
|
||||||
|
uiDesignAssetId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ProjectResourceCardPreviewTransportPayload = Omit<
|
export type ProjectResourceCardPreviewTransportPayload = Omit<
|
||||||
@@ -219,7 +221,8 @@ const markdownExtension = /\.(md|markdown|mdx)$/iu;
|
|||||||
* 与 `resourceProjectionModel` 的 `gameCodeExtension` 是两份口径,刻意不复用:
|
* 与 `resourceProjectionModel` 的 `gameCodeExtension` 是两份口径,刻意不复用:
|
||||||
* 那份用于**筛选与归属**,改动会波及画布栏目与计数;这份只决定**卡面怎么画**。
|
* 那份用于**筛选与归属**,改动会波及画布栏目与计数;这份只决定**卡面怎么画**。
|
||||||
* 这里按用户口径把 `.yaml` / `.toml` / `.xml` / `.html` / `.css` / `.sql`
|
* 这里按用户口径把 `.yaml` / `.toml` / `.xml` / `.html` / `.css` / `.sql`
|
||||||
* 一并算代码;JSON 规格属于文档预览。
|
* 一并算代码。JSON 留在文本读取通道,由原生内容识别区分普通 JSON 和 UI 设计,
|
||||||
|
* 不能像源码卡一样跳过预取;卡面不显示 JSON 正文。
|
||||||
*/
|
*/
|
||||||
const cardCodeExtension =
|
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;
|
/\.(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;
|
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`。 */
|
/** 代码文件的类型标签(如 `.ts` → `TS`);不是代码文件时返回 `null`。 */
|
||||||
export function projectResourceCodeTypeLabel(path: string): string | null {
|
export function projectResourceCodeTypeLabel(path: string): string | null {
|
||||||
const trimmed = path.trim();
|
const trimmed = path.trim();
|
||||||
@@ -256,7 +283,7 @@ export function projectResourceCardPreviewKind(
|
|||||||
return 'document';
|
return 'document';
|
||||||
}
|
}
|
||||||
// Markdown / 代码在扩展名这一层就分流,不再依赖上游登记类型:
|
// Markdown / 代码在扩展名这一层就分流,不再依赖上游登记类型:
|
||||||
// 上游把 JSON 规格登记成「文档」,卡面按文档预览;代码文件按扩展名分流。
|
// JSON 使用文档读取通道完成原生语义识别;源码文件按扩展名分流。
|
||||||
if (markdownExtension.test(resource.path)) {
|
if (markdownExtension.test(resource.path)) {
|
||||||
return 'document';
|
return 'document';
|
||||||
}
|
}
|
||||||
|
|||||||
+1
@@ -167,6 +167,7 @@ function materializeProjectResourceCardPreview(
|
|||||||
pixelHeight: transport.pixelHeight,
|
pixelHeight: transport.pixelHeight,
|
||||||
hasAlpha: transport.hasAlpha,
|
hasAlpha: transport.hasAlpha,
|
||||||
content: transport.content,
|
content: transport.content,
|
||||||
|
uiDesignAssetId: transport.uiDesignAssetId,
|
||||||
},
|
},
|
||||||
retainedBytes:
|
retainedBytes:
|
||||||
transport.content === undefined
|
transport.content === undefined
|
||||||
|
|||||||
@@ -2450,7 +2450,7 @@ export function registerHomeProjectCreationTests() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (command === 'read_direct_project_history_slice') {
|
if (command === 'read_direct_project_history_slice') {
|
||||||
expect(args).toEqual({ projectPath, limit: 20 });
|
expect(args).toEqual({ projectPath, limit: 20, messagesOnly: true });
|
||||||
return { items: [...persistedMessages], hasMore: false };
|
return { items: [...persistedMessages], hasMore: false };
|
||||||
}
|
}
|
||||||
if (command === 'append_local_conversation_message') {
|
if (command === 'append_local_conversation_message') {
|
||||||
@@ -2536,7 +2536,7 @@ export function registerHomeProjectCreationTests() {
|
|||||||
return manifest;
|
return manifest;
|
||||||
}
|
}
|
||||||
if (command === 'read_direct_project_history_slice') {
|
if (command === 'read_direct_project_history_slice') {
|
||||||
expect(args).toEqual({ projectPath, limit: 20 });
|
expect(args).toEqual({ projectPath, limit: 20, messagesOnly: true });
|
||||||
return { items: [...persistedMessages], hasMore: false };
|
return { items: [...persistedMessages], hasMore: false };
|
||||||
}
|
}
|
||||||
if (command === 'append_local_permission_log') {
|
if (command === 'append_local_permission_log') {
|
||||||
|
|||||||
@@ -6043,6 +6043,15 @@ export function registerProjectWorkbenchFoundationTests() {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (command === 'read_local_project_text_preview') {
|
||||||
|
return {
|
||||||
|
path: 'assets/ui-design.json',
|
||||||
|
mediaType: 'application/json',
|
||||||
|
byteLen: 2,
|
||||||
|
content: '{}',
|
||||||
|
uiDesignAssetId: 'ui-design-resource',
|
||||||
|
};
|
||||||
|
}
|
||||||
throw new Error(`unexpected invoke ${command}`);
|
throw new Error(`unexpected invoke ${command}`);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -9124,6 +9133,7 @@ export function registerProjectSupervisorSurfaceTests() {
|
|||||||
expect(invoke).toHaveBeenCalledWith('read_direct_project_history_slice', {
|
expect(invoke).toHaveBeenCalledWith('read_direct_project_history_slice', {
|
||||||
projectPath,
|
projectPath,
|
||||||
limit: 20,
|
limit: 20,
|
||||||
|
messagesOnly: true,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
// 默认任务占位行也不能触发专业 Agent 历史的批量读取。
|
// 默认任务占位行也不能触发专业 Agent 历史的批量读取。
|
||||||
|
|||||||
@@ -0,0 +1,232 @@
|
|||||||
|
/** @vitest-environment jsdom */
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import type { DirectThreadHistorySlice } from '../src/features/project-workspace/directThreadEvents';
|
||||||
|
import {
|
||||||
|
act,
|
||||||
|
App,
|
||||||
|
createGameCreationAppManifest,
|
||||||
|
fireEvent,
|
||||||
|
React,
|
||||||
|
render,
|
||||||
|
screen,
|
||||||
|
setComposerText,
|
||||||
|
} from './appSurface/harness';
|
||||||
|
|
||||||
|
const projectPath = '/tmp/direct-message-pages';
|
||||||
|
const manifest = createGameCreationAppManifest(
|
||||||
|
'direct-message-pages',
|
||||||
|
'历史分页',
|
||||||
|
);
|
||||||
|
const messages = Array.from({ length: 26 }, (_, index) => ({
|
||||||
|
id: `direct-codex:turn-${Math.floor(index / 2)}:${index % 2 ? 'assistant' : 'user'}`,
|
||||||
|
type: 'message',
|
||||||
|
role: index % 2 ? 'assistant' : 'user',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: index % 2 ? 'output_text' : 'input_text',
|
||||||
|
text: `历史正文 ${index}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
function page(
|
||||||
|
items: typeof messages,
|
||||||
|
hasMore: boolean,
|
||||||
|
): DirectThreadHistorySlice {
|
||||||
|
return { items, hasMore, oldestItemId: items[0]?.id ?? null };
|
||||||
|
}
|
||||||
|
function deferred() {
|
||||||
|
let resolve!: (value: DirectThreadHistorySlice) => void;
|
||||||
|
let reject!: (error: Error) => void;
|
||||||
|
const promise = new Promise<DirectThreadHistorySlice>((yes, no) => {
|
||||||
|
resolve = yes;
|
||||||
|
reject = no;
|
||||||
|
});
|
||||||
|
return { promise, resolve, reject };
|
||||||
|
}
|
||||||
|
function install(
|
||||||
|
read: (
|
||||||
|
args: Record<string, unknown>,
|
||||||
|
) => DirectThreadHistorySlice | Promise<DirectThreadHistorySlice>,
|
||||||
|
) {
|
||||||
|
const invoke = vi.fn(
|
||||||
|
async (command: string, args?: Record<string, unknown>) => {
|
||||||
|
if (command === 'read_direct_project_history_slice') {
|
||||||
|
expect(args?.messagesOnly).toBe(true);
|
||||||
|
return read(args!);
|
||||||
|
}
|
||||||
|
if (command === 'read_project_permission_policy')
|
||||||
|
return {
|
||||||
|
path: '.agent/policy.json',
|
||||||
|
policy: { deniedCommands: [], confirmCommands: [] },
|
||||||
|
};
|
||||||
|
if (command === 'get_local_game_manifest') return manifest;
|
||||||
|
if (command === 'read_game_creator_app_config')
|
||||||
|
return {
|
||||||
|
config: {
|
||||||
|
selectedModelId: 'quality',
|
||||||
|
selectedModelIsDefault: true,
|
||||||
|
llm: { customEnabled: false },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (
|
||||||
|
command === 'read_direct_tool_calls' ||
|
||||||
|
command === 'read_direct_turn_stream' ||
|
||||||
|
command === 'list_game_creator_direct_active_turns'
|
||||||
|
)
|
||||||
|
return [];
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
window.__TAURI__ = { core: { invoke } } as unknown as typeof window.__TAURI__;
|
||||||
|
return invoke;
|
||||||
|
}
|
||||||
|
function mount(path = projectPath) {
|
||||||
|
return render(
|
||||||
|
<App
|
||||||
|
initialProjectPath={path}
|
||||||
|
initialProjectManifest={manifest}
|
||||||
|
projectSupervisorOnly
|
||||||
|
orchestrationMode="single-supervisor"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const earlier = () => screen.getByRole('button', { name: /显示更早/ });
|
||||||
|
afterEach(() => {
|
||||||
|
delete window.__TAURI__;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Direct 聊天历史分页集成', () => {
|
||||||
|
it('首屏按消息加载20条,更早消息使用原生游标,原始工具记录不占页', async () => {
|
||||||
|
const invoke = install((args) =>
|
||||||
|
args.beforeItemId
|
||||||
|
? page(messages.slice(0, 6), false)
|
||||||
|
: page(messages.slice(6), true),
|
||||||
|
);
|
||||||
|
mount();
|
||||||
|
await screen.findByText('历史正文 25');
|
||||||
|
expect(screen.queryByText('历史正文 0')).toBeNull();
|
||||||
|
fireEvent.click(earlier());
|
||||||
|
await screen.findByText('历史正文 0');
|
||||||
|
expect(screen.queryByRole('button', { name: /显示更早/ })).toBeNull();
|
||||||
|
expect(invoke).toHaveBeenCalledWith('read_direct_project_history_slice', {
|
||||||
|
projectPath,
|
||||||
|
beforeItemId: messages[6]!.id,
|
||||||
|
limit: 20,
|
||||||
|
messagesOnly: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('重复点击单飞,失败保留游标可重试,重叠消息不重复', async () => {
|
||||||
|
const pending = deferred();
|
||||||
|
let attempts = 0;
|
||||||
|
const invoke = install((args) => {
|
||||||
|
if (!args.beforeItemId) return page(messages.slice(6), true);
|
||||||
|
attempts += 1;
|
||||||
|
return attempts === 1
|
||||||
|
? pending.promise
|
||||||
|
: page(messages.slice(0, 8), false);
|
||||||
|
});
|
||||||
|
mount();
|
||||||
|
await screen.findByText('历史正文 25');
|
||||||
|
fireEvent.click(earlier());
|
||||||
|
fireEvent.click(earlier());
|
||||||
|
expect(attempts).toBe(1);
|
||||||
|
await act(async () => pending.reject(new Error('模拟读取失败')));
|
||||||
|
expect(screen.getByText('历史正文 25')).not.toBeNull();
|
||||||
|
fireEvent.click(earlier());
|
||||||
|
await screen.findByText('历史正文 0');
|
||||||
|
expect(screen.getAllByText('历史正文 6')).toHaveLength(1);
|
||||||
|
expect(screen.getAllByText('历史正文 7')).toHaveLength(1);
|
||||||
|
const loads = invoke.mock.calls.filter(
|
||||||
|
([command, args]) =>
|
||||||
|
command === 'read_direct_project_history_slice' && args?.beforeItemId,
|
||||||
|
);
|
||||||
|
expect(loads.map(([, args]) => args?.beforeItemId)).toEqual([
|
||||||
|
messages[6]!.id,
|
||||||
|
messages[6]!.id,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('同项目重新加载使旧翻页失效,旧 finally 不解除新请求的单飞', async () => {
|
||||||
|
const old = deferred();
|
||||||
|
const fresh = deferred();
|
||||||
|
let fullLoads = 0;
|
||||||
|
let olderLoads = 0;
|
||||||
|
const recent = messages.slice(6).map((item) => ({
|
||||||
|
...item,
|
||||||
|
content: [{ type: 'output_text', text: `重读 ${item.content[0]!.text}` }],
|
||||||
|
}));
|
||||||
|
install((args) => {
|
||||||
|
if (!args.beforeItemId) {
|
||||||
|
fullLoads += 1;
|
||||||
|
return page(fullLoads === 1 ? messages.slice(6) : recent, true);
|
||||||
|
}
|
||||||
|
olderLoads += 1;
|
||||||
|
return olderLoads === 1 ? old.promise : fresh.promise;
|
||||||
|
});
|
||||||
|
mount();
|
||||||
|
await screen.findByText('历史正文 25');
|
||||||
|
fireEvent.click(earlier());
|
||||||
|
await setComposerText(screen.getByLabelText('陶泥儿对话内容'), '/history');
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
||||||
|
await screen.findByText('重读 历史正文 25');
|
||||||
|
fireEvent.click(earlier());
|
||||||
|
await act(async () =>
|
||||||
|
old.resolve(
|
||||||
|
page(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
...messages[0]!,
|
||||||
|
id: 'stale',
|
||||||
|
content: [{ type: 'input_text', text: '过期消息' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(screen.queryByText('过期消息')).toBeNull();
|
||||||
|
fireEvent.click(earlier());
|
||||||
|
expect(olderLoads).toBe(2);
|
||||||
|
await act(async () => fresh.resolve(page(messages.slice(0, 6), false)));
|
||||||
|
await screen.findByText('历史正文 0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('离开项目并重进后,旧请求不能污染新实例', async () => {
|
||||||
|
const old = deferred();
|
||||||
|
const invoke = install((args) =>
|
||||||
|
args.beforeItemId ? old.promise : page(messages.slice(6), true),
|
||||||
|
);
|
||||||
|
const first = mount();
|
||||||
|
await screen.findByText('历史正文 25');
|
||||||
|
fireEvent.click(earlier());
|
||||||
|
first.unmount();
|
||||||
|
const other = mount('/tmp/another-project');
|
||||||
|
await screen.findByText('历史正文 25');
|
||||||
|
other.unmount();
|
||||||
|
mount();
|
||||||
|
await screen.findByText('历史正文 25');
|
||||||
|
await act(async () =>
|
||||||
|
old.resolve(
|
||||||
|
page(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
...messages[0]!,
|
||||||
|
id: 'stale',
|
||||||
|
content: [{ type: 'input_text', text: '旧项目迟到消息' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(screen.queryByText('旧项目迟到消息')).toBeNull();
|
||||||
|
expect(earlier()).not.toBeNull();
|
||||||
|
expect(
|
||||||
|
invoke.mock.calls.filter(
|
||||||
|
([command]) => command === 'read_direct_project_history_slice',
|
||||||
|
),
|
||||||
|
).toHaveLength(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,10 +2,59 @@ import { describe, expect, it } from 'vitest';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
directThreadHistoryItemsToMessages,
|
directThreadHistoryItemsToMessages,
|
||||||
|
directThreadHistoryPage,
|
||||||
isDirectTurnInProgress,
|
isDirectTurnInProgress,
|
||||||
|
prependDirectHistoryMessages,
|
||||||
} from '../src/features/project-workspace/directThreadEvents';
|
} from '../src/features/project-workspace/directThreadEvents';
|
||||||
|
|
||||||
describe('Direct 回合状态与历史时间', () => {
|
describe('Direct 回合状态与历史时间', () => {
|
||||||
|
it('游标来自原始切片,不从没有聊天消息的工具页倒推', () => {
|
||||||
|
const page = directThreadHistoryPage(
|
||||||
|
{
|
||||||
|
items: [{ id: 'tool-older', type: 'function_call_output' }],
|
||||||
|
hasMore: true,
|
||||||
|
oldestItemId: 'tool-older',
|
||||||
|
},
|
||||||
|
'message-newer',
|
||||||
|
);
|
||||||
|
expect(page.messages).toEqual([]);
|
||||||
|
expect(page.cursor).toBe('tool-older');
|
||||||
|
});
|
||||||
|
it('空历史结束,无 ID 或不前进的非终页明确失败而不循环', () => {
|
||||||
|
expect(directThreadHistoryPage({ items: [], hasMore: false })).toEqual({
|
||||||
|
messages: [],
|
||||||
|
cursor: null,
|
||||||
|
hasMore: false,
|
||||||
|
});
|
||||||
|
expect(() => directThreadHistoryPage({ items: [], hasMore: true })).toThrow(
|
||||||
|
'游标未前进',
|
||||||
|
);
|
||||||
|
expect(() =>
|
||||||
|
directThreadHistoryPage(
|
||||||
|
{
|
||||||
|
items: [{ id: 'same' }],
|
||||||
|
hasMore: true,
|
||||||
|
oldestItemId: 'same',
|
||||||
|
},
|
||||||
|
'same',
|
||||||
|
),
|
||||||
|
).toThrow('游标未前进');
|
||||||
|
});
|
||||||
|
it('重叠页按原始 ID 去重,保留当前正文,旧无 ID 消息不删除', () => {
|
||||||
|
const current = [
|
||||||
|
{ role: 'assistant' as const, text: '完整正文', messageId: 'a' },
|
||||||
|
];
|
||||||
|
const old = { role: 'user' as const, text: '用户输入', messageId: 'u' };
|
||||||
|
expect(
|
||||||
|
prependDirectHistoryMessages(current, [
|
||||||
|
old,
|
||||||
|
old,
|
||||||
|
{ role: 'assistant', text: '旧快照', messageId: 'a' },
|
||||||
|
{ role: 'assistant', text: '无身份旧消息' },
|
||||||
|
]),
|
||||||
|
).toEqual([old, { role: 'assistant', text: '无身份旧消息' }, ...current]);
|
||||||
|
});
|
||||||
|
|
||||||
it('终态和空状态不恢复为活动回合', () => {
|
it('终态和空状态不恢复为活动回合', () => {
|
||||||
for (const status of [
|
for (const status of [
|
||||||
'completed',
|
'completed',
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
canDismissResourceCanvasQuickEdit,
|
canDismissResourceCanvasQuickEdit,
|
||||||
isResourceCanvasHostOverlayOpen,
|
isResourceCanvasHostOverlayOpen,
|
||||||
isResourceCanvasInteractionTarget,
|
isResourceCanvasInteractionTarget,
|
||||||
|
isResourceCanvasPanTarget,
|
||||||
isResourceCanvasWheelOverlayTarget,
|
isResourceCanvasWheelOverlayTarget,
|
||||||
resolveResourceCanvasFloatingPanelDismissOpen,
|
resolveResourceCanvasFloatingPanelDismissOpen,
|
||||||
resolveResourceCanvasFloatingPanelOpen,
|
resolveResourceCanvasFloatingPanelOpen,
|
||||||
@@ -41,6 +42,38 @@ const RESOURCE_FOCUS_SOURCE_LAYER: CanvasLayer = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe('resourceCanvasFocusModel', () => {
|
describe('resourceCanvasFocusModel', () => {
|
||||||
|
test('右键抓手允许卡面和选中按钮,不抢媒体控件、编辑器及浮层', () => {
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<div id="blank"></div>
|
||||||
|
<div class="game-resource-card" id="card">
|
||||||
|
<button class="game-resource-card-select" id="select"></button>
|
||||||
|
<button class="game-resource-card-media-control" id="play"></button>
|
||||||
|
<video id="video"></video><input id="input" />
|
||||||
|
</div>
|
||||||
|
<div contenteditable="true" id="editor"></div>
|
||||||
|
<div class="game-resource-filter-panel"><span id="filter">筛选</span></div>
|
||||||
|
<div class="image-canvas-editor__floating-toolbar"><span id="toolbar">操作</span></div>
|
||||||
|
<div class="game-resource-book-scene-titlebar" id="title"></div>
|
||||||
|
`;
|
||||||
|
for (const id of ['blank', 'card', 'select']) {
|
||||||
|
expect(isResourceCanvasPanTarget(document.getElementById(id))).toBe(true);
|
||||||
|
}
|
||||||
|
for (const id of [
|
||||||
|
'play',
|
||||||
|
'video',
|
||||||
|
'input',
|
||||||
|
'editor',
|
||||||
|
'filter',
|
||||||
|
'toolbar',
|
||||||
|
'title',
|
||||||
|
]) {
|
||||||
|
expect(isResourceCanvasPanTarget(document.getElementById(id))).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
expect(isResourceCanvasPanTarget(null)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
test('点在资源卡、交互控件与画布浮层里时不清画布焦点', () => {
|
test('点在资源卡、交互控件与画布浮层里时不清画布焦点', () => {
|
||||||
document.body.innerHTML = `
|
document.body.innerHTML = `
|
||||||
<div class="game-resource-canvas">
|
<div class="game-resource-canvas">
|
||||||
|
|||||||
@@ -340,6 +340,323 @@ afterEach(() => {
|
|||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function mountPointerWorkbench(
|
||||||
|
target: 'main' | 'character' | 'all' = 'character',
|
||||||
|
) {
|
||||||
|
const projectId = 'pointer-workbench';
|
||||||
|
const projectPath = '/tmp/pointer-workbench';
|
||||||
|
const tauri = installLayoutTauri({
|
||||||
|
projectIdsByPath: { [projectPath]: projectId },
|
||||||
|
});
|
||||||
|
render(
|
||||||
|
<LayoutWorkbench
|
||||||
|
projects={[
|
||||||
|
{
|
||||||
|
projectId,
|
||||||
|
projectPath,
|
||||||
|
assets: [
|
||||||
|
pngAsset('pointer-a', 'a.png'),
|
||||||
|
pngAsset('pointer-b', 'b.png'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await settleFocusChain();
|
||||||
|
const manager = document.querySelector<HTMLElement>(
|
||||||
|
'.game-resource-book-manager',
|
||||||
|
)!;
|
||||||
|
vi.spyOn(manager, 'getBoundingClientRect').mockReturnValue({
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
right: 800,
|
||||||
|
bottom: 600,
|
||||||
|
width: 800,
|
||||||
|
height: 600,
|
||||||
|
toJSON: () => ({}),
|
||||||
|
} as DOMRect);
|
||||||
|
act(() => window.dispatchEvent(new Event('resize')));
|
||||||
|
if (target !== 'main') {
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole('button', {
|
||||||
|
name: target === 'all' ? '打开所有资源' : '打开角色与对象',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await settleFocusChain();
|
||||||
|
const surface = manager.querySelector<HTMLElement>(
|
||||||
|
target === 'main'
|
||||||
|
? '.game-resource-book-main'
|
||||||
|
: '.game-resource-book-scene',
|
||||||
|
)!;
|
||||||
|
const capture = new Set<number>();
|
||||||
|
Object.defineProperties(surface, {
|
||||||
|
setPointerCapture: {
|
||||||
|
configurable: true,
|
||||||
|
value: vi.fn((id: number) => capture.add(id)),
|
||||||
|
},
|
||||||
|
hasPointerCapture: {
|
||||||
|
configurable: true,
|
||||||
|
value: (id: number) => capture.has(id),
|
||||||
|
},
|
||||||
|
releasePointerCapture: {
|
||||||
|
configurable: true,
|
||||||
|
value: vi.fn((id: number) => capture.delete(id)),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const world = manager.querySelector<HTMLElement>(
|
||||||
|
'.game-resource-book-scene-world',
|
||||||
|
)!;
|
||||||
|
const viewport = () =>
|
||||||
|
Array.from(world.style.transform.matchAll(/-?\d+(?:\.\d+)?/g), (m) =>
|
||||||
|
Number(m[0]),
|
||||||
|
);
|
||||||
|
return { manager, surface, world, viewport, tauri };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('资源画布指针与运行提示', () => {
|
||||||
|
it('资源卡左键拖动仍提交手动坐标,不平移视口', async () => {
|
||||||
|
const { manager, viewport, tauri } = await mountPointerWorkbench();
|
||||||
|
const card = manager.querySelector<HTMLElement>(
|
||||||
|
'.is-expanded .game-resource-card[data-resource-card-id="asset:pointer-a"]',
|
||||||
|
)!;
|
||||||
|
const before = viewport();
|
||||||
|
const writes = tauri.layoutWrites.length;
|
||||||
|
fireEvent.pointerDown(card, {
|
||||||
|
pointerId: 30,
|
||||||
|
button: 0,
|
||||||
|
clientX: 100,
|
||||||
|
clientY: 100,
|
||||||
|
});
|
||||||
|
fireEvent.pointerMove(card, {
|
||||||
|
pointerId: 30,
|
||||||
|
buttons: 1,
|
||||||
|
clientX: 180,
|
||||||
|
clientY: 140,
|
||||||
|
});
|
||||||
|
fireEvent.pointerUp(card, {
|
||||||
|
pointerId: 30,
|
||||||
|
button: 0,
|
||||||
|
clientX: 180,
|
||||||
|
clientY: 140,
|
||||||
|
});
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(tauri.layoutWrites.length).toBeGreaterThan(writes),
|
||||||
|
);
|
||||||
|
expect(tauri.layoutWrites.at(-1)?.positions).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
resourceId: 'asset:pointer-a',
|
||||||
|
manuallyPlaced: true,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(viewport()).toEqual(before);
|
||||||
|
expect(
|
||||||
|
document.querySelector('.genarrative-image-canvas__selection-overlay'),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('资源选中不移除运行不可用提示或改变运行能力', async () => {
|
||||||
|
const { manager } = await mountPointerWorkbench();
|
||||||
|
const hint = '首个可运行原型尚未完成,运行视图暂不可用';
|
||||||
|
expect(screen.getByText(hint)).not.toBeNull();
|
||||||
|
fireEvent.click(
|
||||||
|
manager.querySelector<HTMLElement>(
|
||||||
|
'.is-expanded [data-resource-id="asset:pointer-a"]',
|
||||||
|
)!,
|
||||||
|
);
|
||||||
|
expect(screen.getByText(hint)).not.toBeNull();
|
||||||
|
const run = screen.getByRole('tab', { name: '运行' });
|
||||||
|
expect(run.getAttribute('data-unavailable')).toBe('true');
|
||||||
|
expect(run.getAttribute('aria-describedby')).toBe('run-unavailable-hint');
|
||||||
|
fireEvent.click(run);
|
||||||
|
expect(run.getAttribute('aria-selected')).toBe('false');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['main', 'character', 'all'] as const)(
|
||||||
|
'%s 的空白处支持右键平移且不写资源布局',
|
||||||
|
async (target) => {
|
||||||
|
const { surface, viewport, tauri } = await mountPointerWorkbench(target);
|
||||||
|
const before = viewport();
|
||||||
|
const writes = tauri.layoutWrites.length;
|
||||||
|
fireEvent.pointerDown(surface, {
|
||||||
|
pointerId: 31,
|
||||||
|
button: 2,
|
||||||
|
clientX: 100,
|
||||||
|
clientY: 100,
|
||||||
|
});
|
||||||
|
fireEvent.pointerMove(surface, {
|
||||||
|
pointerId: 31,
|
||||||
|
buttons: 2,
|
||||||
|
clientX: 170,
|
||||||
|
clientY: 140,
|
||||||
|
});
|
||||||
|
expect(viewport()).toEqual([before[0]! + 70, before[1]! + 40, before[2]]);
|
||||||
|
expect(fireEvent.contextMenu(surface, { button: 2 })).toBe(false);
|
||||||
|
fireEvent.pointerUp(surface, {
|
||||||
|
pointerId: 31,
|
||||||
|
button: 2,
|
||||||
|
clientX: 170,
|
||||||
|
clientY: 140,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
document.querySelector('.genarrative-image-canvas__selection-overlay'),
|
||||||
|
).toBeNull();
|
||||||
|
expect(tauri.layoutWrites).toHaveLength(writes);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('资源卡右键平移保留选中,左键空白拖动仍框选', async () => {
|
||||||
|
const { manager, surface, viewport, tauri } = await mountPointerWorkbench();
|
||||||
|
const card = manager.querySelector<HTMLElement>(
|
||||||
|
'.is-expanded [data-resource-id="asset:pointer-a"]',
|
||||||
|
)!;
|
||||||
|
fireEvent.click(card);
|
||||||
|
const before = viewport();
|
||||||
|
const writes = tauri.layoutWrites.length;
|
||||||
|
fireEvent.pointerDown(card, {
|
||||||
|
pointerId: 32,
|
||||||
|
button: 2,
|
||||||
|
clientX: 100,
|
||||||
|
clientY: 100,
|
||||||
|
});
|
||||||
|
fireEvent.pointerMove(surface, {
|
||||||
|
pointerId: 32,
|
||||||
|
buttons: 2,
|
||||||
|
clientX: 150,
|
||||||
|
clientY: 160,
|
||||||
|
});
|
||||||
|
fireEvent.pointerUp(surface, {
|
||||||
|
pointerId: 32,
|
||||||
|
button: 2,
|
||||||
|
clientX: 150,
|
||||||
|
clientY: 160,
|
||||||
|
});
|
||||||
|
expect(viewport()).toEqual([before[0]! + 50, before[1]! + 60, before[2]]);
|
||||||
|
expect(card.getAttribute('aria-pressed')).toBe('true');
|
||||||
|
expect(tauri.layoutWrites).toHaveLength(writes);
|
||||||
|
const panned = viewport();
|
||||||
|
fireEvent.pointerDown(surface, {
|
||||||
|
pointerId: 33,
|
||||||
|
button: 0,
|
||||||
|
clientX: 10,
|
||||||
|
clientY: 10,
|
||||||
|
});
|
||||||
|
fireEvent.pointerMove(surface, {
|
||||||
|
pointerId: 33,
|
||||||
|
buttons: 1,
|
||||||
|
clientX: 780,
|
||||||
|
clientY: 580,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
document.querySelector('.genarrative-image-canvas__selection-overlay'),
|
||||||
|
).not.toBeNull();
|
||||||
|
expect(selectedResourceIdsInDom()).toContain('asset:pointer-a');
|
||||||
|
expect(viewport()).toEqual(panned);
|
||||||
|
fireEvent.pointerUp(surface, { pointerId: 33, button: 0 });
|
||||||
|
expect(
|
||||||
|
document.querySelector('.genarrative-image-canvas__selection-overlay'),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['cancel', 'capture', 'blur'] as const)(
|
||||||
|
'%s 后右键平移不会继续跟随指针',
|
||||||
|
async (reason) => {
|
||||||
|
const { surface, viewport } = await mountPointerWorkbench();
|
||||||
|
const before = viewport();
|
||||||
|
fireEvent.pointerDown(surface, {
|
||||||
|
pointerId: 34,
|
||||||
|
button: 2,
|
||||||
|
clientX: 100,
|
||||||
|
clientY: 100,
|
||||||
|
});
|
||||||
|
fireEvent.pointerMove(surface, {
|
||||||
|
pointerId: 34,
|
||||||
|
buttons: 2,
|
||||||
|
clientX: 110,
|
||||||
|
clientY: 120,
|
||||||
|
});
|
||||||
|
const moved = viewport();
|
||||||
|
expect(moved).toEqual([before[0]! + 10, before[1]! + 20, before[2]]);
|
||||||
|
if (reason === 'cancel')
|
||||||
|
fireEvent.pointerCancel(surface, { pointerId: 34 });
|
||||||
|
else if (reason === 'capture')
|
||||||
|
fireEvent.lostPointerCapture(surface, { pointerId: 34 });
|
||||||
|
else act(() => window.dispatchEvent(new Event('blur')));
|
||||||
|
fireEvent.pointerMove(surface, {
|
||||||
|
pointerId: 34,
|
||||||
|
clientX: 300,
|
||||||
|
clientY: 300,
|
||||||
|
});
|
||||||
|
expect(viewport()).toEqual(moved);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('总览失焦终止右键平移,子画布切换释放实际捕获节点', async () => {
|
||||||
|
const { surface, viewport } = await mountPointerWorkbench('main');
|
||||||
|
const before = viewport();
|
||||||
|
fireEvent.pointerDown(surface, {
|
||||||
|
pointerId: 36,
|
||||||
|
button: 2,
|
||||||
|
clientX: 10,
|
||||||
|
clientY: 10,
|
||||||
|
});
|
||||||
|
fireEvent.pointerMove(surface, {
|
||||||
|
pointerId: 36,
|
||||||
|
buttons: 2,
|
||||||
|
clientX: 40,
|
||||||
|
clientY: 40,
|
||||||
|
});
|
||||||
|
const moved = viewport();
|
||||||
|
expect(moved).toEqual([before[0]! + 30, before[1]! + 30, before[2]]);
|
||||||
|
act(() => window.dispatchEvent(new Event('blur')));
|
||||||
|
expect(surface.releasePointerCapture).toHaveBeenCalledWith(36);
|
||||||
|
fireEvent.pointerMove(surface, {
|
||||||
|
pointerId: 36,
|
||||||
|
clientX: 100,
|
||||||
|
clientY: 100,
|
||||||
|
});
|
||||||
|
expect(viewport()).toEqual(moved);
|
||||||
|
fireEvent.pointerDown(surface, {
|
||||||
|
pointerId: 37,
|
||||||
|
button: 2,
|
||||||
|
clientX: 10,
|
||||||
|
clientY: 10,
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '打开角色与对象' }));
|
||||||
|
expect(surface.releasePointerCapture).toHaveBeenCalledWith(37);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('控件右键不被画布接管,初次打开不整理也能连续双指平移', async () => {
|
||||||
|
const { manager, surface, viewport } = await mountPointerWorkbench();
|
||||||
|
const zoom = screen.getByRole('button', { name: '放大画布' });
|
||||||
|
const before = viewport();
|
||||||
|
fireEvent.pointerDown(zoom, {
|
||||||
|
pointerId: 35,
|
||||||
|
button: 2,
|
||||||
|
clientX: 100,
|
||||||
|
clientY: 100,
|
||||||
|
});
|
||||||
|
fireEvent.pointerMove(surface, {
|
||||||
|
pointerId: 35,
|
||||||
|
buttons: 2,
|
||||||
|
clientX: 200,
|
||||||
|
clientY: 200,
|
||||||
|
});
|
||||||
|
expect(viewport()).toEqual(before);
|
||||||
|
expect(fireEvent.contextMenu(zoom, { button: 2 })).toBe(true);
|
||||||
|
await act(async () => {
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
fireEvent.wheel(manager, { deltaX: 3, deltaY: 6 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(viewport()).toEqual([before[0]! - 300, before[1]! - 600, before[2]]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('资源画布手动重排口径', () => {
|
describe('资源画布手动重排口径', () => {
|
||||||
it('hook:rederiveNow 按 rederive 策略重算自动坐标并写回一次', async () => {
|
it('hook:rederiveNow 按 rederive 策略重算自动坐标并写回一次', async () => {
|
||||||
const projectId = 'manual-rederive-project';
|
const projectId = 'manual-rederive-project';
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user