合并主线更新并保留后端依赖收敛改动
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 7m22s
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 7m43s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 7m50s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 8m4s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m13s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 3m6s
Project CI / Backend tests (pull_request) Successful in 9m38s
Project CI / Repository checks (pull_request) Successful in 14m27s
Project CI / Native shell tests (pull_request) Successful in 17m24s
Project CI / Frontend tests (pull_request) Successful in 22m13s
Project CI / AI game creator shell web tests (pull_request) Successful in 16m24s

同步最新主线代码与协作约定,保留本次窄依赖、鉴权和追踪实现
This commit is contained in:
kdletters
2026-09-18 20:59:05 +08:00
103 changed files with 17490 additions and 1230 deletions
@@ -14,6 +14,9 @@ Implement the user's actual game request in the current project as an npm-manage
3. Build with the project's npm script before previewing. The playable entry is the package directory's `dist/index.html`; never report an unbuilt bare-module page as playable. Import assets or configure public assets so all runtime media is included in dist; preview and exports cannot read outside it.
4. Build a complete playable loop: visible objective, responsive input, meaningful state changes, success or failure feedback, and a reliable restart path where the game needs one.
5. Fit the active game scene to desktop and mobile viewports without accidental page scrollbars. Reserve deliberate safe space for HUD elements instead of covering interactive content.
- **画布居中只能由一处负责。** 使用 `Phaser.Scale.FIT``autoCenter: Phaser.Scale.CENTER_BOTH` 时,canvas 的直接父容器应使用尺寸明确的普通块布局,不再对同一 canvas 叠加 Grid/Flex 居中、`place-items: center`、自动外边距或居中 transform。Phaser 自动计算的 margin 与 CSS 居中叠加会使竖屏画面向右偏移。
- 若决定由 CSS 居中,则显式使用 `autoCenter: Phaser.Scale.NO_CENTER`,由 CSS 独立完成定位;外围页面可以继续使用 Grid/Flex,限制只针对同一 canvas 的重复定位。
- 出现偏移先检查游戏自身的 CSS 与 Phaser scale 配置,不添加 AGC 预览容器固定偏移补偿。修改布局后重新构建 dist,在桌面、移动及窗口 resize 后检查 canvas 相对游戏父容器居中(误差不超过 1 CSS px)、画面完整且无意外滚动条;不能仅凭 build 成功宣称布局通过。
6. Invoke `taonier-art-assets` for every new game brief that needs visual assets. First reuse suitable registered Taonier art; when the brief's required visual elements are missing or unsuitable, call the reviewed `agc_tools` generation/edit workflow in the same task. After the tool returns, wire its relative paths into the game and verify the rendered result. A game with unused generated assets or placeholder emoji/CSS where requested art should appear is not complete. Load media defensively only for genuinely optional effects, and never relabel a local placeholder as platform art.
7. Let Phaser own the render loop and input dispatch. Avoid duplicate scenes, stale event listeners, and state that survives restart unintentionally.
8. After a meaningful game change, use the browser playtest Skill and fix issues shown by real evidence before reporting completion.
@@ -7,5 +7,7 @@
- Score, steps, health, timer, or other core state updates consistently.
- Restart restores all state and does not duplicate timers, animation loops, or event listeners.
- Desktop and mobile layouts keep the core scene visible without accidental document scrolling.
- 画布的缩放与居中由 Phaser 或 CSS 中的一方独立负责。`FIT + CENTER_BOTH` 不与同一 canvas 父容器的 Grid/Flex 居中、自动外边距或居中 transform 叠加;使用 CSS 居中时关闭 Phaser 自动居中(`NO_CENTER`)。
- 在构建后的实际页面检查桌面、移动和 resize:比较 canvas 与游戏父容器的中心,预期居中时水平/垂直误差不超过 1 CSS px,并检查画面没有溢出或意外滚动条。偏移先修游戏 CSS/scale 配置,不用修改 AGC 预览位置掩盖。
- HUD and overlays reserve space and do not cover essential interactive content.
- Requested Taonier art is visibly integrated into the core experience when available.
@@ -1,6 +1,6 @@
{
"schemaVersion": "agc-skill-pack.v1",
"version": "2026-08-26.24",
"version": "2026-08-26.25",
"skills": [
{
"name": "agc-game-production-workflow",
@@ -80,7 +80,7 @@
"agents/openai.yaml",
"references/game-quality-checklist.md"
],
"sha256": "05b5cfbf7a40fd303717491f5cea84ff339a73359c9678b283fd54d2b5c45efd"
"sha256": "e122d8f3a6d986b594b95c971754d68197bf7896912fa8267d44a7aa129a57ba"
},
{
"name": "agc-browser-playtest",
@@ -797,6 +797,16 @@ fn direct_thread_visible_item(
direct_thread_event_item(root, item)
}
/// AGC 预写的 canonical 用户条目 id`direct-codex:{clientTurnId}:user`。
///
/// 与 `direct_project_history::is_direct_project_codex_user_item` 的判据同一份口径(前缀 +
/// `:user` 后缀)。回合生命周期事件的 `userItemId` 只能来自这里或已落盘条目自身的 id;
/// clientTurnId 缺失时不猜身份,返回 `None` 让前端按"未知归属"处理。
fn direct_codex_user_item_id_for_client_turn_id(client_turn_id: &str) -> Option<String> {
let client_turn_id = client_turn_id.trim();
(!client_turn_id.is_empty()).then(|| format!("direct-codex:{client_turn_id}:user"))
}
fn direct_codex_command_is_game_verification(command: &str) -> bool {
let command = command.to_ascii_lowercase();
command.contains("game.static_smoke")
@@ -2927,7 +2937,7 @@ impl CodexAppServerConnection {
None => direct_project_local_message_item(
"user",
current_prompt,
Some(&format!("direct-codex:{client_turn_id}:user")),
direct_codex_user_item_id_for_client_turn_id(client_turn_id).as_deref(),
)
.map_err(platform_llm::LlmError::InvalidRequest)?,
};
@@ -3046,13 +3056,30 @@ impl CodexAppServerConnection {
};
turn_start_guard.armed = false;
let direct_thread_id = direct_thread_id_for_project(history_root);
// 回合边界的阶段时间:Turn 上游只有**秒**级 `startedAt` / `completedAt`,秒级截断
// 撑不起前端 0.1 秒粒度的展示,也可能让完成时刻落进该轮用户消息的同一秒、落在真实
// 发送时间之前,被判成无效边界后整轮新回合被吞掉。因此这里只在宿主处理对应阶段时取
// 毫秒钟(与条目侧"没有原生阶段时间就用宿主钟"同一口径),不再读上游秒字段。
let direct_turn_started_at_ms = direct_tool_call_now_ms();
// 本轮开口用户条目的 canonical id:只从已落盘的那条条目上读身份(`id`,工具条目才用
// `call_id`),不在事件侧重造一份。拿不到就留空,让前端按"归属不可证明"处理。
let direct_turn_user_item_id = direct_persisted_user_item
.as_ref()
.and_then(direct_thread_item_identity);
if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
append_direct_thread_event(&direct_thread_id, DirectThreadEvent::turn_started());
append_direct_thread_event(
&direct_thread_id,
DirectThreadEvent::turn_started(direct_turn_started_at_ms)
.with_user_item_id(direct_turn_user_item_id.as_deref()),
);
if let Some(user_item) = direct_persisted_user_item.as_ref() {
if let Some(entry_item) = direct_thread_event_item(history_root, user_item) {
// 这里的条目时间可能是启动应答后的观测时间;前端按同一用户条目身份
// 保留更早的真实发送时间,不用此事件时间覆盖它。
let user_item_at = entry_item.at();
append_direct_thread_event(
&direct_thread_id,
DirectThreadEvent::item_completed(entry_item),
DirectThreadEvent::item_completed(entry_item, user_item_at),
);
}
}
@@ -3191,9 +3218,14 @@ impl CodexAppServerConnection {
.map_err(platform_llm::LlmError::InvalidRequest)?;
direct_project_history.complete_item(&item);
if let Some(entry_item) = entry_item {
// `rawResponseItem/completed` 不带阶段时间,宿主处理到这条
// 通知的钟就是该阶段唯一可证明的时间。
append_direct_thread_event(
&direct_thread_id,
DirectThreadEvent::item_completed(entry_item),
DirectThreadEvent::item_completed(
entry_item,
direct_tool_call_now_ms(),
),
);
}
}
@@ -3322,9 +3354,19 @@ impl CodexAppServerConnection {
if let Some(entry_item) =
direct_thread_visible_item(history_root, item)
{
// `item/started` 的通知层带 `startedAtMs`:这是工具真正
// 开始的阶段时间,优先于条目展示时间与宿主钟。
append_direct_thread_event(
&direct_thread_id,
DirectThreadEvent::item_started(entry_item),
DirectThreadEvent::item_started(
entry_item,
direct_thread_item_event_at_ms(
&params,
item,
false,
direct_tool_call_now_ms(),
),
),
);
}
}
@@ -3367,9 +3409,19 @@ impl CodexAppServerConnection {
&& matches!(status, "completed" | "interrupted" | "failed")
{
terminal_recorded = true;
// 终态时间:`durationMs` 与宿主记下的毫秒起点都可靠时才派生,
// 否则取宿主处理这条终态的钟;上游秒级 `completedAt` 一律不用。
append_direct_thread_event(
&direct_thread_id,
DirectThreadEvent::turn_completed(status.to_string()),
DirectThreadEvent::turn_completed(
status.to_string(),
direct_thread_turn_completed_at_ms(
turn,
Some(direct_turn_started_at_ms),
direct_tool_call_now_ms(),
),
)
.with_user_item_id(direct_turn_user_item_id.as_deref()),
);
}
match status {
@@ -3426,7 +3478,11 @@ impl CodexAppServerConnection {
"failed"
}
.to_string(),
),
// 这条兜底终态没有对应的 app-server 终态载荷,只能取宿主处理它的钟,
// 不能拿最后一次正文或工具更新时间当回合终点。
direct_tool_call_now_ms(),
)
.with_user_item_id(direct_turn_user_item_id.as_deref()),
);
}
let text = match collect_result {
@@ -3624,6 +3680,15 @@ enum DirectCodexTurnCancelTarget {
/// 这时显式释放这条守卫并把可读原因返回给界面。释放条件见
/// [`release_stale_direct_taonier_active_invocation`] 的注释;"正在跑的是另一轮"仍然
/// 保持原拒绝语义,什么都不释放。
///
/// 兜底终态带 `userItemId`:身份取 `release_stale_direct_taonier_active_invocation` 返回的
/// clientTurnId(客户端回合身份的唯一来源),与正常路径的开口条目 id 同一份 canonical 口径。
/// 拿不到 clientTurnId 就留空——这一轮不会再有原生终态,猜一个身份会让前端把边界盖到别人身上。
fn direct_stale_cancel_turn_completed_event(client_turn_id: &str) -> DirectThreadEvent {
DirectThreadEvent::turn_completed("aborted".to_string(), direct_tool_call_now_ms())
.with_user_item_id(direct_codex_user_item_id_for_client_turn_id(client_turn_id).as_deref())
}
pub(crate) fn cancel_direct_codex_turn_at(
root: &Path,
client_turn_id: Option<&str>,
@@ -3680,7 +3745,7 @@ pub(crate) fn cancel_direct_codex_turn_at(
// 兜底补一条,否则前端的"最新回合是否在跑"会永远停在运行中。
append_direct_thread_event(
&direct_thread_id_for_project(root),
DirectThreadEvent::turn_completed("aborted".to_string()),
direct_stale_cancel_turn_completed_event(&released),
);
Ok(DirectTurnCancelView {
outcome: DIRECT_TURN_CANCEL_OUTCOME_RELEASED.to_string(),
@@ -5006,6 +5071,109 @@ mod tests {
));
}
/// 阶段时间取自**通知层**字段,形状照抄 codex-cli 0.147 / 0.155 的 v2 协议 schema
/// `item/started` 带 `startedAtMs`、`item/completed` 带 `completedAtMs`(毫秒),
/// `turn/completed` 带 `turn.startedAt` / `turn.completedAt`(秒)与 `turn.durationMs`(毫秒)。
/// 分类函数把 params 原样交给事件级 `at` 的投影函数,所以字段位置必须在这里钉住;
/// 回合边界的秒字段按"不用"锁在这里,避免以后有人再把秒级截断当 0.1 秒精度。
#[test]
fn direct_lifecycle_stage_times_come_from_notification_params() {
let started = serde_json::json!({
"threadId": "thread-1",
"turnId": "turn-1",
"startedAtMs": 1_700_000_000_123u64,
"item": {"id": "call-1", "type": "commandExecution", "command": "ls"},
});
let completed = serde_json::json!({
"threadId": "thread-1",
"turnId": "turn-1",
"completedAtMs": 1_700_000_001_500u64,
"item": {"id": "call-1", "type": "commandExecution", "command": "ls"},
});
for (method, params, expected_at_ms) in [
("item/started", &started, 1_700_000_000_123u64),
("item/completed", &completed, 1_700_000_001_500u64),
] {
let Some(CodexTurnEvent::Item {
completed,
params: event_params,
}) = direct_codex_notification_event(method, params, None, None, "turn-1")
else {
panic!("{method} 必须分类成条目生命周期事件");
};
let item = event_params.get("item").expect("item payload");
assert_eq!(
direct_thread_item_event_at_ms(&event_params, item, completed, 9_999),
expected_at_ms,
"{method} 必须用通知层的阶段时间,而不是宿主钟"
);
}
let terminal = serde_json::json!({
"threadId": "thread-1",
"turn": {
"id": "turn-1",
"items": [],
"status": "completed",
"startedAt": 1_700_000_000i64,
"completedAt": 1_700_000_042i64,
},
});
let Some(CodexTurnEvent::Terminal(params)) =
direct_codex_notification_event("turn/completed", &terminal, None, None, "turn-1")
else {
panic!("turn/completed 必须分类成终态事件");
};
let turn = params.get("turn").unwrap_or(&params);
assert_eq!(
direct_thread_turn_completed_at_ms(turn, Some(1_700_000_000_500), 9_999),
9_999,
"上游只有秒级 completedAt:不采用,取宿主处理终态的毫秒钟"
);
let with_duration = serde_json::json!({
"id": "turn-1",
"items": [],
"status": "completed",
"startedAt": 1_700_000_000i64,
"completedAt": 1_700_000_042i64,
"durationMs": 42_500u64,
});
assert_eq!(
direct_thread_turn_completed_at_ms(&with_duration, Some(1_700_000_000_500), 9_999),
1_700_000_043_000,
"durationMs + 宿主高精度起点才派生结束"
);
}
/// 取消兜底终态也要带开口用户条目身份,且身份只有一个来源:release 返回的 clientTurnId
/// 走与正常路径同一份 canonical 口径;拿不到(空 / 空白)就留空,不猜。
#[test]
fn stale_cancel_terminal_event_keeps_opener_user_item_id_from_client_turn_id() {
let event = direct_stale_cancel_turn_completed_event("turn-0001");
assert_eq!(event.user_item_id(), Some("direct-codex:turn-0001:user"));
assert!(event.at().is_some(), "兜底终态仍要带宿主观测时间");
assert!(matches!(
event,
DirectThreadEvent::TurnCompleted { ref status, .. } if status == "aborted"
));
for missing in ["", " "] {
let event = direct_stale_cancel_turn_completed_event(missing);
assert_eq!(
event.user_item_id(),
None,
"拿不到 clientTurnId 时不得编造开口条目身份"
);
}
// canonical 口径与落盘侧同一份:`direct-codex:{clientTurnId}:user`。
assert_eq!(
direct_codex_user_item_id_for_client_turn_id(" turn-0001 ").as_deref(),
Some("direct-codex:turn-0001:user")
);
assert_eq!(direct_codex_user_item_id_for_client_turn_id(""), None);
}
fn test_llm() -> GameCreatorLlmConfig {
GameCreatorLlmConfig {
custom_enabled: false,
@@ -6740,12 +6908,33 @@ done
let consumed = crate::agent::consume_direct_thread(&bootstrap.subscription_id)
.expect("consume events");
// 回合起止必须与开口用户条目同源:前端在「只有锚点 + 历史、运行态为空」的回合里靠这个
// 身份把边界认领给同一条用户条目,缺了它就只能隐藏未知用时。
let lifecycle_user_item_ids = consumed
.events
.iter()
.filter(|event| {
matches!(
event,
DirectThreadEvent::TurnStarted { .. } | DirectThreadEvent::TurnCompleted { .. }
)
})
.map(DirectThreadEvent::user_item_id)
.collect::<Vec<_>>();
assert_eq!(
lifecycle_user_item_ids,
vec![
Some("direct-codex:turn-0001:user"),
Some("direct-codex:turn-0001:user"),
],
"turn.started / turn.completed 都要带本轮开口用户条目的 canonical itemId"
);
let mut user_items = Vec::new();
let mut assistant_items = Vec::new();
for event in &consumed.events {
let item = match event {
DirectThreadEvent::ItemStarted { item }
| DirectThreadEvent::ItemCompleted { item } => item,
DirectThreadEvent::ItemStarted { item, .. }
| DirectThreadEvent::ItemCompleted { item, .. } => item,
_ => continue,
};
match item {
File diff suppressed because one or more lines are too long
@@ -427,12 +427,16 @@ mod tests {
}
}
/// 事件级阶段时间只在重放稳定性用例里逐个指定;其余用例用一个固定值即可,
/// 它们断言的是队列 / 游标语义,不是时间本身。
const FIXED_AT_MS: u64 = 1_000;
fn item_started(item_id: &str) -> DirectThreadEvent {
DirectThreadEvent::item_started(message(item_id))
DirectThreadEvent::item_started(message(item_id), FIXED_AT_MS)
}
fn item_completed(item_id: &str) -> DirectThreadEvent {
DirectThreadEvent::item_completed(message(item_id))
DirectThreadEvent::item_completed(message(item_id), FIXED_AT_MS)
}
fn item_delta(item_id: &str) -> DirectThreadEvent {
@@ -450,7 +454,7 @@ mod tests {
#[test]
fn subscribers_have_independent_cursors_on_one_global_queue() {
let mut manager = DirectThreadManager::with_limits(100, 100_000);
manager.append("thread-1", DirectThreadEvent::turn_started());
manager.append("thread-1", DirectThreadEvent::turn_started(FIXED_AT_MS));
let first = manager.subscribe("thread-1");
let second = manager.subscribe("thread-1");
manager.append("thread-1", item_started("item-1"));
@@ -474,7 +478,7 @@ mod tests {
#[test]
fn bootstrap_contains_lifecycle_anchor_and_unfinished_events_only() {
let mut manager = DirectThreadManager::with_limits(100, 100_000);
manager.append("thread-1", DirectThreadEvent::turn_started());
manager.append("thread-1", DirectThreadEvent::turn_started(FIXED_AT_MS));
manager.append("thread-1", item_started("item-1"));
manager.append("thread-1", item_delta("item-1"));
manager.append("thread-1", item_completed("item-1"));
@@ -484,7 +488,7 @@ mod tests {
assert!(matches!(
bootstrap.events.as_slice(),
[
DirectThreadEvent::TurnStarted {},
DirectThreadEvent::TurnStarted { .. },
DirectThreadEvent::ItemStarted { item, .. },
] if item.item_id() == "item-2"
));
@@ -589,15 +593,118 @@ mod tests {
let mut manager = DirectThreadManager::with_limits(100, 100_000);
manager.append(
"thread-1",
DirectThreadEvent::turn_completed("completed".to_string()),
DirectThreadEvent::turn_completed("completed".to_string(), FIXED_AT_MS),
);
let bootstrap = manager.subscribe("thread-1");
assert!(matches!(
bootstrap.events.as_slice(),
[DirectThreadEvent::TurnCompleted { status }] if status == "completed"
[DirectThreadEvent::TurnCompleted { status, at, .. }]
if status == "completed" && *at == Some(FIXED_AT_MS)
));
}
/// 阶段时间必须随事件一起进队列:bootstrap 与重复订阅都拿到**原值**,
/// 重放不得重新取钟(否则每次重连都会把已固定的起止时间改掉)。
#[test]
fn replayed_events_keep_their_original_stage_time() {
let mut manager = DirectThreadManager::with_limits(100, 100_000);
manager.append("thread-1", DirectThreadEvent::turn_started(1_000));
manager.append(
"thread-1",
DirectThreadEvent::item_started(message("item-1"), 2_000),
);
let first = manager.subscribe("thread-1");
assert_eq!(
first
.events
.iter()
.map(DirectThreadEvent::at)
.collect::<Vec<_>>(),
vec![Some(1_000), Some(2_000)]
);
// 第二个订阅看到的是同一份事件,时间不因"又取了一次当前时间"而漂移。
let second = manager.subscribe("thread-1");
assert_eq!(second.events, first.events);
manager.append(
"thread-1",
DirectThreadEvent::item_completed(message("item-1"), 3_000),
);
let completion = manager
.consume(&first.subscription_id)
.expect("consume completion")
.events;
assert_eq!(
completion
.iter()
.map(DirectThreadEvent::at)
.collect::<Vec<_>>(),
vec![Some(3_000)]
);
// 重复消费不产生新事件,也不改写已下发过的时间。
assert!(manager
.consume(&first.subscription_id)
.expect("empty consume")
.events
.is_empty());
assert_eq!(
completion
.iter()
.map(DirectThreadEvent::at)
.collect::<Vec<_>>(),
vec![Some(3_000)]
);
}
/// 生命周期锚点重放时必须带上开口用户条目身份:前端在「只有锚点 + 历史切片、运行态一直空」
/// 的回合里也要能把边界认领给同一条用户条目,而不是按时间戳猜。
#[test]
fn bootstrap_replays_opener_user_item_id() {
let mut manager = DirectThreadManager::with_limits(100, 100_000);
manager.append(
"thread-1",
DirectThreadEvent::turn_started(1_000)
.with_user_item_id(Some("direct-codex:turn-1:user")),
);
let bootstrap = manager.subscribe("thread-1");
assert_eq!(
bootstrap
.events
.iter()
.map(DirectThreadEvent::user_item_id)
.collect::<Vec<_>>(),
vec![Some("direct-codex:turn-1:user")]
);
assert_eq!(bootstrap.events[0].at(), Some(1_000));
// 锚点是独立保存的副本:队列里那条事件被回收之后,新订阅仍拿到同一个身份。
manager
.consume(&bootstrap.subscription_id)
.expect("consume anchor");
manager.append(
"thread-1",
DirectThreadEvent::item_completed(message("item-1"), 2_000),
);
manager.append(
"thread-1",
DirectThreadEvent::turn_completed("completed".to_string(), 3_000)
.with_user_item_id(Some("direct-codex:turn-1:user")),
);
let second = manager.subscribe("thread-1");
assert_eq!(
second
.events
.iter()
.map(DirectThreadEvent::user_item_id)
.collect::<Vec<_>>(),
vec![Some("direct-codex:turn-1:user")],
"起止同源:终态锚点也带同一个开口用户条目身份"
);
assert_eq!(second.events[0].at(), Some(3_000));
}
#[test]
fn queue_cleanup_only_removes_a_cleanable_prefix() {
let mut manager = DirectThreadManager::with_limits(100, 100_000);
File diff suppressed because it is too large Load Diff
@@ -2391,6 +2391,8 @@ async fn bridge_generate_image(state: &DirectToolBridgeState, arguments: &Value)
slice_mode,
grid_x,
grid_y,
reference_asset_ids: Vec::new(),
target_category: None,
screen_color,
};
let _generation_guard = state.image_generation_gate.lock().await;
@@ -67,10 +67,11 @@ pub(crate) use canvas_generation::{
generate_platform_art_asset_with_options_at,
generate_platform_art_asset_with_required_slices_at, maybe_generate_platform_art_asset_step,
needs_platform_art_asset_generation, normalize_platform_art_asset_generation_kind,
normalize_platform_art_reference_asset_ids, normalize_platform_art_target_category,
platform_art_asset_art_spec, platform_art_asset_output_extension_matches,
prepare_platform_art_asset_output_path, project_canvas_asset_media_types,
role_has_canvas_assets, suggested_canvas_tool_call, PlatformArtAssetGenerationOptions,
PLATFORM_ART_ASSET_GENERATION_KINDS,
platform_art_runtime_references_match_request_contract, prepare_platform_art_asset_output_path,
project_canvas_asset_media_types, role_has_canvas_assets, suggested_canvas_tool_call,
PlatformArtAssetGenerationOptions, PLATFORM_ART_ASSET_GENERATION_KINDS,
};
#[allow(unused_imports)]
pub(crate) use draft_validation::{
File diff suppressed because it is too large Load Diff
@@ -577,6 +577,8 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio
slice_mode: (!slice_mode.trim().is_empty()).then_some(slice_mode.clone()),
grid_x,
grid_y,
reference_asset_ids: Vec::new(),
target_category: None,
screen_color: None,
};
if let Some(pending) = pending_action {
@@ -629,6 +631,9 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio
.or_else(|| (!slice_mode.trim().is_empty()).then_some(slice_mode)),
grid_x,
grid_y,
reference_asset_ids: requested_options.reference_asset_ids,
// Agent 运行时不会指定完成登记的目标栏目,保持调用方给的值(默认 `None`)。
target_category: requested_options.target_category,
screen_color: requested_options.screen_color,
}
};
@@ -390,6 +390,12 @@ pub(crate) async fn start_local_project_asset_generation(
image_size: Option<String>,
asset_name: Option<String>,
output_path: Option<String>,
// 前端 IPC 字段 `referenceAssetIds`:当前项目 manifest 里的图片素材 id,只做参考输入,
// 不进任务账本(重试由调用方继续用同一份引用提交,账本本身不新增字段)。
reference_asset_ids: Option<Vec<String>>,
// 前端 IPC 字段 `targetCategory`:完成登记时要落盘的正式栏目分类。同样不进任务账本:
// 它与引用一样属于「同一次提交的本地落点」,重试由调用方继续用同一个栏目提交。
target_category: Option<String>,
) -> Result<AssetGenerationTaskRecord, String> {
let task_id = asset_generation_task_id(&task_id)?;
let request = prepare_local_project_asset_generation(
@@ -400,6 +406,8 @@ pub(crate) async fn start_local_project_asset_generation(
image_size.as_deref(),
asset_name.as_deref(),
output_path.as_deref(),
reference_asset_ids.as_deref().unwrap_or_default(),
target_category.as_deref(),
)?;
enforce_project_permission_policy(&request.root, "canvas.asset_generate")?;
enforce_project_permission_policy(&request.root, "asset.register")?;
@@ -1,5 +1,6 @@
use super::*;
use sha2::{Digest as _, Sha256};
use shared_contracts::game_creation_app::GameCreationAppAssetCategory;
use std::future::Future;
const PRIVATE_EXTERNAL_EDITOR_API_KEY_FILE_PREFIX: &str = "external-editor-api-";
@@ -662,6 +663,7 @@ pub(crate) fn register_design_artifacts_at(root: &Path) -> Result<bool, String>
generation_kind: None,
reference_resource_ids: Vec::new(),
},
None,
)?;
changed |= asset_changed;
}
@@ -1876,8 +1878,56 @@ pub(crate) fn register_local_asset_entry(
id_prefix: &str,
source: GameCreationAppAssetSource,
) -> Result<UploadLocalAssetResult, String> {
register_local_asset_entry_with_change(root, local_path, kind, media_type, id_prefix, source)
.map(|(result, _)| result)
register_local_asset_entry_with_change(
root, local_path, kind, media_type, id_prefix, source, None,
)
.map(|(result, _)| result)
}
/// 带**显式目标分类**的登记入口:只给 GUI 生成完成路径用(前端 `targetCategory`)。
///
/// 入口栏目与生成 kind 不是同一套词汇(栏目 `character` / `scene` / `ui-interaction`
/// 生成 kind 的派生分类会把图片落到 `unclassified`、规范图落到 `document`),所以要落回
/// 入口栏目只能由调用方把目标分类显式交进来。取值必须先过
/// [`shared_contracts::game_creation_app::game_creation_app_asset_category_from_str`]
/// 非法值失败关闭,绝不回退到 kind 派生;其它调用方继续走
/// [`register_local_asset_entry`],行为不变。
pub(crate) fn register_local_asset_entry_with_category(
root: &Path,
local_path: &str,
kind: &str,
media_type: &str,
id_prefix: &str,
source: GameCreationAppAssetSource,
target_category: Option<&str>,
) -> Result<UploadLocalAssetResult, String> {
let target_category = normalize_asset_category_override(target_category)?;
register_local_asset_entry_with_change(
root,
local_path,
kind,
media_type,
id_prefix,
source,
target_category,
)
.map(|(result, _)| result)
}
/// 归一显式目标分类:只接受合法枚举值,返回落盘字符串。
fn normalize_asset_category_override(
target_category: Option<&str>,
) -> Result<Option<GameCreationAppAssetCategory>, String> {
let Some(target_category) = target_category else {
return Ok(None);
};
let target_category = target_category.trim();
if target_category.is_empty() {
return Ok(None);
}
game_creation_app_asset_category_from_str(target_category)
.map(Some)
.ok_or_else(|| format!("非法资源分类:{target_category}"))
}
fn register_local_asset_entry_with_change(
@@ -1887,6 +1937,7 @@ fn register_local_asset_entry_with_change(
media_type: &str,
id_prefix: &str,
source: GameCreationAppAssetSource,
target_category: Option<GameCreationAppAssetCategory>,
) -> Result<(UploadLocalAssetResult, bool), String> {
let normalized_path = normalize_relative_path(local_path)?;
let absolute_path = resolve_local_project_path(root, &normalized_path)?;
@@ -1912,11 +1963,17 @@ fn register_local_asset_entry_with_change(
// kind 没变时刻意不动 category——落盘分类是权威值,同 kind 重登记不得抹掉它。
let changed = existing.kind != kind
|| existing.media_type != media_type
|| existing.source != source;
|| existing.source != source
|| target_category.is_some_and(|category| existing.category != category);
if existing.kind != kind {
existing.kind = kind.to_string();
existing.category = game_creation_app_asset_category_for_kind(kind);
}
// 调用方显式给出目标分类时它就是权威值:GUI 完成登记必须能落回入口栏目,
// 这也是同路径重新生成时把资产从旧栏目(或 unclassified)原位接管过来的唯一入口。
if let Some(category) = target_category {
existing.category = category;
}
existing.media_type = media_type.to_string();
existing.source = source;
Ok((existing.id.clone(), "asset.update", changed))
@@ -1933,7 +1990,8 @@ fn register_local_asset_entry_with_change(
local_path: normalized_path.clone(),
image_sequence_frames: None,
image_sequence_duration_ms: None,
category: game_creation_app_asset_category_for_kind(kind),
category: target_category
.unwrap_or_else(|| game_creation_app_asset_category_for_kind(kind)),
tags: Vec::new(),
source,
});
@@ -2153,6 +2211,7 @@ pub(crate) fn delete_manifest_asset_at(
#[cfg(test)]
mod tests {
use super::*;
use shared_contracts::game_creation_app::GameCreationAppAssetCategory;
use std::io::{Read, Write};
#[test]
@@ -2176,6 +2235,121 @@ mod tests {
assert!(!register_design_artifacts_at(root).expect("register idempotently"));
}
/// GUI 完成登记可以显式指定目标栏目:新建条目与已登记条目都按显式值落盘。
///
/// 入口栏目(character / scene / ui-interaction)与生成 kind 不是同一套词汇,按 kind 派生
/// 会把图片落到 unclassified,占位拿不回原位;非法值必须失败关闭,不传时保持 kind 派生。
#[test]
fn explicit_target_category_overrides_the_kind_derived_category() {
fn canvas_source() -> GameCreationAppAssetSource {
GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Canvas,
canvas_project_id: None,
resource_id: None,
asset_object_id: None,
task_id: None,
prompt: None,
model: None,
generation_route: None,
generation_kind: None,
reference_resource_ids: Vec::new(),
}
}
fn category_of(root: &Path, asset_id: &str) -> GameCreationAppAssetCategory {
read_existing_manifest_for_project(root)
.expect("read manifest")
.assets
.into_iter()
.find(|asset| asset.id == asset_id)
.expect("registered asset is present")
.category
}
let temporary = tempfile::tempdir().expect("tempdir");
let root = temporary.path();
crate::project::init_local_game_project_at(root, "target-category-test", "目标栏目登记")
.expect("init project");
fs::create_dir_all(root.join("assets")).expect("create assets dir");
fs::write(root.join("assets/hero.png"), b"png-bytes").expect("write asset");
let registered = register_local_asset_entry_with_category(
root,
"assets/hero.png",
"image",
"image/png",
"platform-art",
canvas_source(),
Some("character"),
)
.expect("register with a target category");
assert_eq!(
category_of(root, &registered.id),
GameCreationAppAssetCategory::Character
);
// 同 kind 重新生成时显式目标分类仍是权威值:资产要能换栏目原位接管。
register_local_asset_entry_with_category(
root,
"assets/hero.png",
"image",
"image/png",
"platform-art",
canvas_source(),
Some("ui-interaction"),
)
.expect("re-register with another target category");
assert_eq!(
category_of(root, &registered.id),
GameCreationAppAssetCategory::UiInteraction
);
// 非法值失败关闭,且不动已落盘的分类。
assert!(register_local_asset_entry_with_category(
root,
"assets/hero.png",
"image",
"image/png",
"platform-art",
canvas_source(),
Some("version"),
)
.is_err());
assert_eq!(
category_of(root, &registered.id),
GameCreationAppAssetCategory::UiInteraction
);
// 不传目标分类时保持原有行为:新条目按 kind 派生(image → unclassified)。
fs::write(root.join("assets/plain.png"), b"png-bytes").expect("write plain asset");
let plain = register_local_asset_entry(
root,
"assets/plain.png",
"image",
"image/png",
"platform-art",
canvas_source(),
)
.expect("register without a target category");
assert_eq!(
category_of(root, &plain.id),
GameCreationAppAssetCategory::Unclassified
);
// 已落盘的显式分类在 kind 未变时仍然是权威值:同 kind 重登记不得把它抹掉。
register_local_asset_entry(
root,
"assets/hero.png",
"image",
"image/png",
"platform-art",
canvas_source(),
)
.expect("re-register without a target category");
assert_eq!(
category_of(root, &registered.id),
GameCreationAppAssetCategory::UiInteraction
);
}
/// 画板导出推断出的 kind 必须已经是 canonical 值。
///
/// 这个值会被原样写进 manifest 并据以派生落盘 `category`;一旦写出非 canonical 值
@@ -2375,6 +2375,27 @@ pub(crate) fn update_local_project_resource_classification(
)
}
/// 为一批已登记素材追加标签:整批一次校验、一次 manifest 写入、一次 revision 推进。
///
/// 权限位与单素材分类更新同口径取 `asset.register`(命令包装层只做权限门面,
/// 身份 / 写锁 / CAS / 原子写与审计都在 `project/manifest.rs` 内完成)。
/// 这里刻意**不**循环调用单素材命令:逐项调用会写出多份 manifest、推进多次 revision
/// 中途失败还会留下"前几个素材改了、后面的没改"的部分写入。
#[tauri::command]
pub(crate) fn add_local_project_resource_tags(
input: AddLocalProjectResourceTagsInput,
) -> Result<AddLocalProjectResourceTagsResult, String> {
let root = Path::new(input.project_path.trim());
enforce_project_permission_policy(root, "asset.register")?;
add_manifest_asset_tags_at(
root,
&input.expected_project_id,
input.expected_project_revision,
input.asset_ids,
input.tags,
)
}
#[tauri::command]
pub(crate) async fn derive_local_project_resource(
input: DeriveLocalProjectResourceInput,
@@ -4926,6 +4947,8 @@ pub(crate) fn prepare_local_project_asset_generation(
image_size: Option<&str>,
asset_name: Option<&str>,
output_path: Option<&str>,
reference_asset_ids: &[String],
target_category: Option<&str>,
) -> Result<LocalProjectAssetGenerationRequest, String> {
let project_path = project_path.trim();
if project_path.is_empty() {
@@ -4933,6 +4956,13 @@ pub(crate) fn prepare_local_project_asset_generation(
}
let asset_kind = normalize_platform_art_asset_generation_kind(kind)
.ok_or_else(|| format!("素材类型不受支持:{}", kind.trim()))?;
// 参考入参只接受当前项目 manifest 素材 id:路径、远端 resourceId 与超限在这里就被拒绝,
// 不把校验推迟到远端(远端只该收到当前账号绑定下的 resource ID)。
let reference_asset_ids =
normalize_platform_art_reference_asset_ids(asset_kind, reference_asset_ids)?;
// GUI 完成登记层参数:入口栏目与生成 kind 不是同一套词汇,只有调用方显式给出目标分类
// 才能把产物原位落回入口栏目。非法值(含 `version` / `all` 这类栏目伪值)直接失败关闭。
let target_category = normalize_platform_art_target_category(target_category)?;
Ok(LocalProjectAssetGenerationRequest {
root: PathBuf::from(project_path),
prompt: local_project_asset_prompt(prompt)?,
@@ -4969,6 +4999,8 @@ pub(crate) fn prepare_local_project_asset_generation(
.then(|| "connected-components".to_string()),
grid_x: None,
grid_y: None,
reference_asset_ids,
target_category,
screen_color: None,
},
})
@@ -4991,6 +5023,10 @@ pub(crate) async fn generate_local_project_asset(
image_size: Option<String>,
asset_name: Option<String>,
output_path: Option<String>,
reference_asset_ids: Option<Vec<String>>,
// 前端 IPC 字段 `targetCategory`:本次生成完成登记时要落盘的正式栏目分类,
// 只走 GUI 命令,取值必须是合法素材分类,Agent / Direct 路径不传。
target_category: Option<String>,
) -> Result<UploadLocalAssetResult, String> {
let request = prepare_local_project_asset_generation(
&project_path,
@@ -5000,6 +5036,8 @@ pub(crate) async fn generate_local_project_asset(
image_size.as_deref(),
asset_name.as_deref(),
output_path.as_deref(),
reference_asset_ids.as_deref().unwrap_or_default(),
target_category.as_deref(),
)?;
enforce_project_permission_policy(&request.root, "canvas.asset_generate")?;
enforce_project_permission_policy(&request.root, "asset.register")?;
@@ -5018,7 +5056,17 @@ mod local_project_asset_generation_tests {
use super::*;
fn prepare(kind: &str, prompt: &str) -> Result<LocalProjectAssetGenerationRequest, String> {
prepare_local_project_asset_generation("/tmp/project", kind, prompt, None, None, None, None)
prepare_local_project_asset_generation(
"/tmp/project",
kind,
prompt,
None,
None,
None,
None,
&[],
None,
)
}
#[test]
@@ -5058,6 +5106,8 @@ mod local_project_asset_generation_tests {
Some("2K"),
Some(" 主角图集 "),
Some(" assets/hero.png "),
&[],
None,
)
.expect("explicit options");
assert_eq!(explicit.root, PathBuf::from("/tmp/project"));
@@ -5085,8 +5135,18 @@ mod local_project_asset_generation_tests {
#[test]
fn invalid_toolbar_arguments_are_rejected_before_any_generation() {
assert_eq!(
prepare_local_project_asset_generation("", "image", "要求", None, None, None, None)
.expect_err("empty project path"),
prepare_local_project_asset_generation(
"",
"image",
"要求",
None,
None,
None,
None,
&[],
None,
)
.expect_err("empty project path"),
"项目路径不能为空"
);
assert_eq!(
@@ -5097,6 +5157,60 @@ mod local_project_asset_generation_tests {
prepare("game-art", "要求").expect_err("unverified kind"),
"素材类型不受支持:game-art"
);
// 目标分类只接受合法素材分类枚举:栏目侧伪值 `version` / `all` 与任意其它值都失败关闭。
for rejected in ["version", "all", "bogus", "UI"] {
assert_eq!(
prepare_local_project_asset_generation(
"/tmp/project",
"image",
"要求",
None,
None,
None,
None,
&[],
Some(rejected),
)
.expect_err("illegal target category"),
format!("目标分类不是合法素材分类:{rejected}")
);
}
// 合法值归一成落盘字符串(trim + kebab-case),供 manifest `category` 直接使用。
assert_eq!(
prepare_local_project_asset_generation(
"/tmp/project",
"image",
"要求",
None,
None,
None,
None,
&[],
Some(" ui-interaction "),
)
.expect("legal target category")
.options
.target_category
.as_deref(),
Some("ui-interaction")
);
assert_eq!(
prepare_local_project_asset_generation(
"/tmp/project",
"image",
"要求",
None,
None,
None,
None,
&[],
None,
)
.expect("omitted target category")
.options
.target_category,
None
);
assert_eq!(
prepare(
"spec",
@@ -5113,7 +5227,9 @@ mod local_project_asset_generation_tests {
Some("4:3"),
None,
None,
None
None,
&[],
None,
)
.expect_err("unsupported ratio"),
"图片比例不受支持:4:3"
@@ -5126,7 +5242,9 @@ mod local_project_asset_generation_tests {
None,
Some("4K"),
None,
None
None,
&[],
None,
)
.expect_err("unsupported size"),
"图片尺寸不受支持:4K"
@@ -5139,7 +5257,9 @@ mod local_project_asset_generation_tests {
None,
None,
Some("\u{7}名字"),
None
None,
&[],
None,
)
.expect_err("control character in asset name"),
"素材名称超出安全边界"
@@ -5152,7 +5272,9 @@ mod local_project_asset_generation_tests {
None,
None,
None,
Some(&"a".repeat(LOCAL_PROJECT_ASSET_MAX_OUTPUT_PATH_CHARS + 1))
Some(&"a".repeat(LOCAL_PROJECT_ASSET_MAX_OUTPUT_PATH_CHARS + 1)),
&[],
None,
)
.expect_err("oversized output path"),
"输出路径超出安全边界"
@@ -2589,6 +2589,7 @@ fn main() {
register_local_asset,
create_ui_design_resource,
update_local_project_resource_classification,
add_local_project_resource_tags,
derive_local_project_resource,
list_pending_local_project_resource_edits,
resume_local_project_resource_edit,
@@ -1131,8 +1131,17 @@ pub(crate) fn validate_manifest_required_visual_asset(
}
if task_id == "art-director" {
if !asset.source.reference_resource_ids.is_empty() {
return Err("统一视觉规范图不得声明派生资源引用".to_string());
// 规范图是视觉来源链的根:它自身不派生任何视觉资产,但 icon-spec 生成允许用户参考
// (没有规范前置,最多总上限),这些参考只是风格输入,不构成派生关系。这里改为验证
// 参考集合仍符合 icon-spec 请求合同;route / generation kind / canvasProjectId /
// resourceId / PNG 解码等身份判据全部保持不变。
if !crate::agent::platform_art_runtime_references_match_request_contract(
&asset.source.reference_resource_ids,
expected_kind,
) {
return Err(format!(
"统一视觉规范图的参考集合不符合请求合同:{expected_path}"
));
}
return Ok(());
}
@@ -1157,11 +1166,17 @@ pub(crate) fn validate_manifest_required_visual_asset(
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| "统一视觉规范图缺少 resourceId".to_string())?;
let [reference_resource_id] = asset.source.reference_resource_ids.as_slice() else {
// 派生素材的参考合同是「规范图前置在最前,用户参考按顺序追加在后」,图集不接受用户参考:
// 规范身份仍只由首项承担,用户参考不能顶替也不能冒充规范引用。
if !crate::agent::platform_art_runtime_references_match_request_contract(
&asset.source.reference_resource_ids,
expected_kind,
) {
return Err(format!(
"派生视觉资产未精确引用当前统一视觉规范图:{expected_path}"
));
};
}
let reference_resource_id = asset.source.reference_resource_ids[0].as_str();
let original_provenance_matches =
canvas_project_id == art_spec_project_id && reference_resource_id == art_spec_resource_id;
let rebound_local_source_matches = if original_provenance_matches {
@@ -1328,6 +1343,240 @@ pub(crate) fn update_manifest_asset_classification_at(
})
}
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub(crate) struct AddLocalProjectResourceTagsInput {
pub(crate) project_path: String,
pub(crate) expected_project_id: String,
pub(crate) expected_project_revision: u64,
pub(crate) asset_ids: Vec<String>,
#[serde(default)]
pub(crate) tags: Vec<String>,
}
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct AddLocalProjectResourceTagsResult {
pub(crate) assets: Vec<GameCreationAppAssetManifestEntry>,
pub(crate) committed_project_revision: u64,
}
/// 一次批量追加的素材上限:与主规范「每批最多 200 个不同素材」一致,按**去重后**数量计算。
/// 批次越大,锁内要重算的合并结果越多,manifest 也越大;无界批次会把成本摊到之后每一次读写上。
pub(crate) const ASSET_BATCH_TAG_MAX_ASSETS: usize = 200;
/// 批量追加标签的素材 ID 归一化:trim、按**首次出现顺序**去重,再在此处收口批次上下界。
///
/// 这里是"整句拒绝"的失败关闭口径,不做任何静默容忍:
///
/// - 空白 `assetId` 直接失败,不 `continue` 跳过。静默跳过会让"请求了 N 个素材"和"实际写了
/// N-1 个"分叉,而调用方拿到的仍是成功——这正是本合同要排除的静默部分写;
/// - 空批次失败;
/// - 去重后超限立即失败(在扫描到第 201 个不同 ID 时就返回,不对剩余 ID 继续做去重扫描),
/// 更不做"截断到 200 个":截断会让用户以为 250 个素材都加上了标签。
fn normalize_manifest_batch_asset_ids(asset_ids: &[String]) -> Result<Vec<String>, String> {
let mut normalized: Vec<String> = Vec::new();
for asset_id in asset_ids {
let asset_id = asset_id.trim();
if asset_id.is_empty() {
return Err("批量标签 assetId 不能为空".to_string());
}
if !normalized.iter().any(|existing| existing == asset_id) {
normalized.push(asset_id.to_string());
if normalized.len() > ASSET_BATCH_TAG_MAX_ASSETS {
return Err(format!(
"批量标签最多支持 {ASSET_BATCH_TAG_MAX_ASSETS} 个素材"
));
}
}
}
if normalized.is_empty() {
return Err("批量标签至少需要一个素材".to_string());
}
Ok(normalized)
}
/// 批量追加的标签归一化:沿用主规范的 trim / 去空 / 去重口径(复用
/// [`normalize_manifest_asset_tags`],其中已含数量与单标签长度收口)。
///
/// 只有**归一后为空**才拒绝:请求里全是空白标签时,用户填的东西一个字都不会落盘,
/// 此时若当成"成功且无变化"返回,界面会显示保存成功而素材上什么都没有。
fn normalize_manifest_batch_tags(tags: &[String]) -> Result<Vec<String>, String> {
let normalized = normalize_manifest_asset_tags(tags)?;
if normalized.is_empty() {
return Err("批量标签不能为空".to_string());
}
Ok(normalized)
}
/// 追加语义:只把请求里**尚不存在**的标签按请求顺序补到原有标签之后。
/// 原有标签的顺序、分类、类型、路径与来源都不参与改写——本命令没有删除或替换语义。
fn merge_manifest_asset_tags(existing: &[String], incoming: &[String]) -> Vec<String> {
let mut merged = existing.to_vec();
for tag in incoming {
if !merged.iter().any(|current| current == tag) {
merged.push(tag.clone());
}
}
merged
}
/// 锁内先算完的整批计划:任何一项缺失或超限都在这里失败,此时 manifest 一个字节都没动。
struct ManifestAssetTagAppendPlan {
/// 按请求顺序(去重后)返回的素材条目,标签为合并后的完整列表。
assets: Vec<GameCreationAppAssetManifestEntry>,
/// 真正需要落值的目标:`(assets 下标, 合并后的标签)`。
updates: Vec<(usize, Vec<String>)>,
/// 确实发生变化的素材 ID,供审计记录使用;空表示整批无变化。
changed_asset_ids: Vec<String>,
}
/// 先校验**全部**目标与**全部**合并结果,再决定是否写值。
///
/// 顺序是刻意的:第一阶段只读,任一目标不存在、任一合并结果超过标签上界都在写之前返回错误;
/// 只有全部通过,第二阶段才逐项落值。这样"缺任一资产 / 超限"都不可能留下部分写入。
fn plan_manifest_asset_tag_append(
manifest: &GameCreationAppManifest,
asset_ids: &[String],
tags: &[String],
) -> Result<ManifestAssetTagAppendPlan, String> {
let mut assets = Vec::with_capacity(asset_ids.len());
let mut updates: Vec<(usize, Vec<String>)> = Vec::with_capacity(asset_ids.len());
let mut changed_asset_ids = Vec::new();
for asset_id in asset_ids {
let index = manifest
.assets
.iter()
.position(|asset| &asset.id == asset_id)
.ok_or_else(|| format!("项目资源不存在:{asset_id}"))?;
let asset = &manifest.assets[index];
// 合并结果复用同一个上界函数:已有标签已归一化,这里等价于对整份新列表再收口一次。
// 上界函数只报"16 个"这种通用口径,200 个素材的批次里看不出是哪一项超了,所以在**调用点**
// 补上目标身份(ID + 可读 localPath)并说明整批未写:用户要能直接定位到那一张素材。
let merged = normalize_manifest_asset_tags(&merge_manifest_asset_tags(&asset.tags, tags))
.map_err(|error| {
format!(
"素材 {}{})的标签合并结果不合法:{error};本次未写入任何素材",
asset.id, asset.local_path
)
})?;
if merged != asset.tags {
changed_asset_ids.push(asset.id.clone());
}
updates.push((index, merged.clone()));
assets.push(GameCreationAppAssetManifestEntry {
tags: merged,
..asset.clone()
});
}
Ok(ManifestAssetTagAppendPlan {
assets,
updates,
changed_asset_ids,
})
}
/// 为一批已登记素材追加标签:一次校验、一次 manifest 写入、一次 revision 推进。
///
/// 语义与 [`update_manifest_asset_classification_at`] 同源(`asset.register` 权限位、项目身份、
/// 项目写锁、revision CAS、manifest 原子写、审计在 manifest 落盘之后 / revision 推进之前),
/// 但作用域是**整批**
///
/// - 项目身份校验两次(进入前与持锁后各一次),锁内按 `expectedProjectRevision` 做一次 CAS
/// - 锁内先算完整批计划,任一目标缺失或任一合并结果超限都**不写任何一项**;
/// - 整批无变化时**不写盘、不审计、不推进 revision**,直接返回当前条目与当前 revision;
/// - 真正有变化时才写一次 manifest、追加一条审计、推进一次 revision。
///
/// 已落盘之后的审计或 revision 失败照实报"整批已写入",不回滚、也不谎称回滚:manifest 是权威
/// 真相且已经改变,把错误说成"没写"只会让用户拿错状态去重试。
pub(crate) fn add_manifest_asset_tags_at(
root: &Path,
expected_project_id: &str,
expected_project_revision: u64,
asset_ids: Vec<String>,
tags: Vec<String>,
) -> Result<AddLocalProjectResourceTagsResult, String> {
if expected_project_revision
> shared_contracts::game_creation_app::GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION
{
return Err("expectedProjectRevision 超出 JavaScript 安全整数范围".to_string());
}
let expected_project_id = expected_project_id.trim();
if expected_project_id.is_empty() {
return Err("批量标签 expectedProjectId 不能为空".to_string());
}
let asset_ids = normalize_manifest_batch_asset_ids(&asset_ids)?;
let tags = normalize_manifest_batch_tags(&tags)?;
if read_existing_manifest_for_project(root)?.project_id != expected_project_id {
return Err("project-identity-conflict".to_string());
}
// 锁的 commandId 用本命令自己的动作名(审计/排障时能区分是批量追加还是别的写路径);
// 权限门面仍然是 `asset.register`,见 `commands.rs` 的命令包装层。
let _lock = acquire_project_write_lock(root, ASSET_BATCH_TAG_AUDIT_RECORD_TYPE)?;
if read_existing_manifest_for_project(root)?.project_id != expected_project_id {
return Err("project-identity-conflict".to_string());
}
if read_game_creator_agent_runtime_project_revision(root)?.revision != expected_project_revision
{
return Err("project-revision-conflict".to_string());
}
// no-op 判定发生在锁内、写盘之前:整批标签都已经存在时,连 manifest 都不必重写一次。
// 这不是优化洁癖——重写会换掉文件 mtime 与内容字节,让"什么都没做"看起来像一次真实改动。
let plan = plan_manifest_asset_tag_append(
&read_existing_manifest_for_project(root)?,
&asset_ids,
&tags,
)?;
if plan.changed_asset_ids.is_empty() {
return Ok(AddLocalProjectResourceTagsResult {
assets: plan.assets,
committed_project_revision: expected_project_revision,
});
}
let plan = mutate_manifest_at(root, |manifest| {
// 锁内复核:`mutate_manifest_at` 自己重新读盘,所以这里按同一套规则重算一遍再落值。
// 复核失败会在 `write_manifest_locked` 之前返回错误,仍然零写入;重算也保证不会拿
// 锁外算出的绝对标签列表去覆盖这份 manifest 上刚出现的新标签。
let plan = plan_manifest_asset_tag_append(manifest, &asset_ids, &tags)?;
for (index, merged) in &plan.updates {
manifest.assets[*index].tags = merged.clone();
}
Ok(plan)
})?;
// 复核阶段才发现"锁外以为有变化、锁内其实已无变化"的极端竞态:这一次写盘写出的就是原内容,
// 不能凭空补一条审计或推进 revision。正常路径不会走到这里——整批目标在此之前已经通过锁内 no-op 判定。
if plan.changed_asset_ids.is_empty() {
return Ok(AddLocalProjectResourceTagsResult {
assets: plan.assets,
committed_project_revision: expected_project_revision,
});
}
append_agent_db_record(
root,
serde_json::json!({
"recordType": ASSET_BATCH_TAG_AUDIT_RECORD_TYPE,
"assetIds": plan.changed_asset_ids,
"expectedProjectRevision": expected_project_revision,
"appendedTags": tags,
}),
)
.map_err(|error| format!("批量标签已写入,但审计记录失败:{error}"))?;
let committed_project_revision = advance_agent_runtime_project_revision_locked(root)
.map_err(|error| format!("批量标签已写入,但项目 revision 未能推进:{error}"))?;
Ok(AddLocalProjectResourceTagsResult {
assets: plan.assets,
committed_project_revision,
})
}
/// 批量标签写入的审计类型:一次批量追加只留一条记录,装的是"谁被追加了什么"。
pub(crate) const ASSET_BATCH_TAG_AUDIT_RECORD_TYPE: &str = "asset.tags.append";
pub(crate) fn create_manifest_task_at(
root: &Path,
task_id: &str,
File diff suppressed because it is too large Load Diff
@@ -6927,6 +6927,22 @@ fn agent_native_function_catalog_exposes_each_runtime_tool_with_core_schemas() {
"sliceCount"
])
);
let canvas_properties = &canvas_asset.parameters["properties"]["input"]["properties"];
assert_eq!(
canvas_properties["sliceMode"]["enum"],
serde_json::json!(["connected-components", "grid", null])
);
for field in ["gridX", "gridY", "sliceCount"] {
assert_eq!(
canvas_properties[field]["type"],
serde_json::json!(["integer", "null"])
);
assert_eq!(canvas_properties[field]["minimum"], 1);
assert_eq!(
canvas_properties[field]["maximum"],
if field == "sliceCount" { 256 } else { 32 }
);
}
assert_eq!(
canvas_asset.parameters["properties"]["input"]["properties"]["aspectRatio"]["enum"],
serde_json::json!(["1:1", "2:3", "3:2", "9:16", "16:9", null])
+16 -2
View File
@@ -241,7 +241,9 @@ import { readDirectHistoryPages } from './features/project-workspace/directHisto
import {
applyDirectThreadConsumeResult,
type DirectThreadChatState,
directThreadTurnMatchesUser,
emptyDirectThreadChatState,
finishDirectThreadTurn,
mergeDirectHistoryItems,
resolveDirectThreadBootstrap,
selectDirectChatEntries,
@@ -12188,8 +12190,19 @@ export function App({
const message = result?.message?.trim();
if (result?.outcome === 'released') {
// 这一轮已经没有人替它收尾(执行进程已退出 / 从没进执行器),Rust 侧强制释放了
// 守卫并补了终态事件;这里同步把界面复位,不等 IPC 通知。
setDirectThreadChat((state) => ({ ...state, turnRunning: false }));
// 守卫并补了终态事件;这里按同一个收口函数同步把界面复位,不等 IPC 通知。
// 时刻取宿主观测到的这一刻:终止返回就是这一轮的终态,原生随后补的事件若先到,
// 收口已经是冻结值,不会被抬高,也不会复活成"永远运行中"。
// 但取消回包可能晚于新回合的开始:先核对身份(clientTurnId 对应的本轮用户条目),
// 只收口确实是这一轮的那一次,避免在新回合的回调里把旧轮的时间盖上来。
const cancelledUserItemId = result.clientTurnId
? directCodexConversationMessageId(result.clientTurnId, 'user')
: '';
setDirectThreadChat((state) =>
directThreadTurnMatchesUser(state, cancelledUserItemId)
? finishDirectThreadTurn(state, Date.now())
: state,
);
setChatAgentBusy(false);
setProjectSupervisorRuntimeError('');
setChatComposerNotice(
@@ -12399,6 +12412,7 @@ export function App({
chatProjectAssets={chatProjectAssets}
directCodex={directCodexProductRuntime}
directTurnRunning={directCodexProductRuntime && directTurnRunning}
directTurnStartedAt={directThreadChat.turnStartedAt}
directEntries={
directCodexProductRuntime
? selectDirectChatEntries(directThreadChat)
@@ -1,5 +1,6 @@
import { useState } from 'react';
import { formatElapsedDuration } from '../../../../../packages/shared/src/lib/formatElapsedDuration';
import { resolveTauriInvoke } from '../../app/tauri';
import type {
PlanGddDecisionAction,
@@ -171,7 +172,7 @@ export function PlanGddStageProgress({
: `当前版本:v${latestVersion}`}
</span>
{processingSeconds > 0 ? (
<span>{`处理耗时:${processingSeconds.toFixed(1)}`}</span>
<span>{`处理耗时:${formatElapsedDuration(processingSeconds * 1000) ?? '—'}`}</span>
) : null}
</div>
{deliveredGdd ? (
@@ -1,4 +1,12 @@
import { ArrowUp, AtSign, Loader2, Settings } from 'lucide-react';
import {
ArrowUp,
AtSign,
ChevronDown,
Lightbulb,
Loader2,
Settings,
Wrench,
} from 'lucide-react';
import type {
ComponentProps,
FormEventHandler,
@@ -12,6 +20,7 @@ import {
AgentMessageContent,
type AgentMessageTone,
} from '../../../../../packages/shared/src/components/AgentMessageContent';
import { AgentProcessSummary } from '../../../../../packages/shared/src/components/AgentProcessSummary';
import type {
AgentStatusCard,
ChatMessage,
@@ -41,6 +50,7 @@ import {
import type { DirectCodexTurnAttachment } from '../app-shell/directCodexTurnAttachments';
import { formatAgentCardRuntimeStatus } from '../project-summary/agentPresentation';
import { taskStatusLabels } from '../project-summary/projectSummary';
import { agentProcessPreview } from './agentProcessPreview';
import type { QueuedChatTurn } from './chatComposerQueue';
import {
ComposerPendingAttachments,
@@ -81,8 +91,29 @@ import type { ChatComposerDraft, ChatReference } from './resourceReferences';
import { ToolCallGroup } from './ToolCallGroup';
import {
formatClockTime,
formatTurnDuration,
resolveToolGroupTiming,
resolveTurnTiming,
} from './toolCallGroupPresentation';
import { useLiveNow } from './useLiveNow';
/**
* 运行中整轮总耗时:自带 100ms 时钟的小叶子。
*
* 时钟只驱动这一行文字(`useLiveNow`),不带着整个对话面板每 100ms 重建。
* 起点拿不到时不渲染:不编造不能证明的耗时。
*/
function TurnElapsedTotal({ startedAt }: { startedAt: number }) {
const now = useLiveNow(startedAt > 0);
const timing = resolveTurnTiming({ startedAt, running: true, now });
if (!timing.durationText) {
return null;
}
return (
<em className="project-supervisor-process-elapsed">
{`总耗时 ${timing.durationText}`}
</em>
);
}
/** 当前 Agent 和策划 Agent 的实时/历史思考使用同一个折叠入口。 */
function AgentReasoning({
@@ -94,6 +125,9 @@ function AgentReasoning({
label?: string;
testId?: string;
}) {
const [expanded, setExpanded] = useState(false);
// 折叠态:单行纯文本预览(走 Markdown AST 取文字,链接只留字面文字、不含目标)。
const preview = agentProcessPreview(text);
return (
<AgentMessageContent
as="details"
@@ -101,9 +135,19 @@ function AgentReasoning({
className="design-agent-reasoning"
aria-label={label}
data-testid={testId}
onToggle={(event) =>
setExpanded((event.currentTarget as HTMLDetailsElement).open)
}
>
<summary></summary>
<pre>{text}</pre>
<summary>
<AgentProcessSummary
icon={<Lightbulb size={12} aria-hidden="true" />}
preview={expanded ? '思考过程' : preview || '思考过程'}
chevron={<ChevronDown size={12} aria-hidden="true" />}
/>
</summary>
{/* 展开态复用助手正文的安全 Markdown 链路(内部 skipHtml,不用 innerHTML)。 */}
<ChatMarkdownMessage role="assistant" text={text} />
</AgentMessageContent>
);
}
@@ -125,6 +169,8 @@ type ProjectSupervisorViewProps = RuntimePanelProps & {
directEntries?: DirectChatEntry[];
/** 最新回合是否还在跑;只由生命周期事件决定。 */
directTurnRunning?: boolean;
/** 最新回合的原生起点(`turn.started.at`):用户发送时间缺失时兜底,0 = 缺失。 */
directTurnStartedAt?: number;
hiddenConversationCount: number;
hasEarlierConversationMessages?: boolean;
messagesRef: RefObject<HTMLDivElement | null>;
@@ -195,6 +241,7 @@ export function ProjectSupervisorView({
directCodex = false,
directEntries = [],
directTurnRunning = false,
directTurnStartedAt = 0,
hiddenConversationCount,
hasEarlierConversationMessages = false,
messagesRef,
@@ -246,8 +293,6 @@ export function ProjectSupervisorView({
const planningSurfaceActive =
planningLane || isPlanningLaneRuntime(runtimePanelProps.runtime);
const [settingsOpen, setSettingsOpen] = useState(false);
// 整轮会话的耗时在回合进行中要每秒刷新:用 tick 驱动的 `now` 计算"现在 - 开始"。
const [turnUsageNow, setTurnUsageNow] = useState(() => Date.now());
// 语音输入的降级/失败提示:不支持时按钮本身就带提示,这里只承载启动失败与权限类错误。
const [voiceNotice, setVoiceNotice] = useState('');
const [approvalOpen, setApprovalOpen] = useState(false);
@@ -267,41 +312,65 @@ export function ProjectSupervisorView({
const [modelValidating, setModelValidating] = useState(false);
const modelSelectRef = useRef<ConversationModelSelectHandle>(null);
const modelValidateInFlightRef = useRef(false);
useEffect(() => {
if (!directTurnRunning) {
return;
}
setTurnUsageNow(Date.now());
const timer = setInterval(() => setTurnUsageNow(Date.now()), 1000);
return () => clearInterval(timer);
}, [directTurnRunning]);
const runBusy = runtimePanelProps.controlBusy || submitting;
const directTurns = directCodex
? buildDirectChatTurns({
entries: directEntries,
localMessages: conversationMessages,
turnRunning: directTurnRunning,
turnStartedAt: directTurnStartedAt,
})
: [];
const activeTurnStartedAt =
directTurns.find((turn) => turn.active)?.startedAt ?? 0;
const latestDirectTurn = directTurns.at(-1);
// 仅是首个响应到达前的临时提示,不创建聊天条目或第二套回合状态。
const awaitingFirstResponse =
directCodex &&
(runBusy || directTurnRunning) &&
!turnCancelling &&
!runtimePanelProps.error &&
Boolean(latestDirectTurn?.users.length) &&
latestDirectTurn?.process.length === 0 &&
latestDirectTurn?.finals.length === 0;
// 初始占位气泡只是"最初那条消息还没有正式条目"时的顶位,两种情况下不再渲染:
// - 已经翻出更早的历史(`hasEarlierConversationMessages`):这里不是对话开头,不补占位;
// - Direct 模式已经有了正式用户条目:正式气泡自己会显示,占位再渲染就是同一条消息出现两次。
// 这里按**结构**判断(存在正式用户条目),不按文本去重真实消息,也不影响合法的连续重复发送。
const initialSupervisorText = initialSupervisorMessage.trim();
const initialSupervisorPlaceholderSuperseded =
hasEarlierConversationMessages ||
(directCodex
? directEntries.some(
(entry) => entry.kind === 'message' && entry.role === 'user',
) || conversationMessages.some((message) => message.role === 'user')
: conversationMessages.some(
(message) =>
message.role === 'user' &&
message.text.trim() === initialSupervisorText,
));
const clockTimeWithSeconds = (timestamp: number) => {
const date = new Date(timestamp);
const pad = (value: number) => String(value).padStart(2, '0');
return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
};
/**
* 回合结束后的一行小结:时间范围与总耗时读同一组边界,两者都取不到就不渲染——
* 旧历史没有完整边界时不猜"这一轮跑了多久"。终态不会再变,这里不用时钟。
*/
const renderTurnUsage = (turn: DirectChatTurn) => {
if (turn.active || !turn.startedAt) return null;
const endedAt = Math.max(turn.endedAt, turn.startedAt);
if (turn.active) return null;
const timing = resolveTurnTiming({
startedAt: turn.startedAt,
endedAt: turn.endedAt,
});
if (!timing.durationText) return null;
const endedLabel = formatClockTime(turn.endedAt, { tenths: true });
return (
<p
className="message-turn-usage"
data-testid="turn-usage"
data-turn-running="false"
>
{`本轮结束于 ${clockTimeWithSeconds(endedAt)} · 耗时 ${formatTurnDuration(endedAt - turn.startedAt) ?? '0秒'}`}
{endedLabel
? `本轮结束于 ${endedLabel} · 总耗时 ${timing.durationText}`
: `总耗时 ${timing.durationText}`}
</p>
);
};
@@ -435,12 +504,7 @@ export function ProjectSupervisorView({
: '显示更早的对话'}
</button>
) : null}
{initialSupervisorMessage.trim() &&
!conversationMessages.some(
(message) =>
message.role === 'user' &&
message.text.trim() === initialSupervisorMessage.trim(),
) ? (
{initialSupervisorText && !initialSupervisorPlaceholderSuperseded ? (
<div className="message message--user" data-runtime-owned="true">
<ChatMarkdownMessage
role="user"
@@ -456,6 +520,15 @@ export function ProjectSupervisorView({
.reverse()
.find((block) => block.kind === 'assistant')?.key ?? null)
: null;
// 外层"执行过程"汇总**这一轮全部工具调用**(不是过程段落数,也不含思考段):
// 计数 = 所有组的 calls 之和;耗时 = 首工具开始 → 末工具完成(各组的并集跨度,
// 不是整轮总耗时,也不与整轮那处重复);边界不完整时隐藏。
const turnToolCalls = turn.process.flatMap((block) =>
block.kind === 'tools' ? block.calls : [],
);
const turnToolSpan = resolveToolGroupTiming(turnToolCalls, {
running: false,
});
const renderBlock = (
block: DirectChatBlock,
tone: AgentMessageTone = 'body',
@@ -465,7 +538,6 @@ export function ProjectSupervisorView({
<ToolCallGroup
key={block.key}
calls={block.calls}
userSentAt={turn.startedAt}
active={turn.active}
className="message-tool-call"
/>
@@ -524,7 +596,24 @@ export function ProjectSupervisorView({
className="message-turn-process"
data-testid="turn-process"
>
<summary></summary>
<summary>
<AgentProcessSummary
icon={<Wrench size={12} aria-hidden="true" />}
preview={
turnToolCalls.length > 0
? `执行了 ${turnToolCalls.length} 个操作`
: '执行过程'
}
meta={
turnToolSpan.durationText
? `,耗时 ${turnToolSpan.durationText}`
: ''
}
chevron={
<ChevronDown size={12} aria-hidden="true" />
}
/>
</summary>
<div className="message-turn-process-body">
{turn.process.map((block) =>
renderBlock(block, 'process'),
@@ -573,6 +662,18 @@ export function ProjectSupervisorView({
</AgentMessageContent>
</div>
) : null}
{awaitingFirstResponse ? (
<AgentMessageContent
tone="process"
className="agent-awaiting-response"
data-testid="agent-awaiting-response"
role="status"
aria-live="polite"
>
<Lightbulb size={12} aria-hidden="true" />
<span></span>
</AgentMessageContent>
) : null}
</div>
{directCodex ? null : planningSurfaceActive ? (
<PlanningLaneRuntimeStrip
@@ -651,15 +752,7 @@ export function ProjectSupervisorView({
<header>
<span aria-hidden="true" />
<strong></strong>
{activeTurnStartedAt > 0 ? (
<em className="project-supervisor-process-elapsed">
{`已耗时 ${
formatTurnDuration(
Math.max(0, turnUsageNow - activeTurnStartedAt),
) ?? '0秒'
}`}
</em>
) : null}
<TurnElapsedTotal startedAt={activeTurnStartedAt} />
</header>
</AgentMessageContent>
) : null}
@@ -478,8 +478,9 @@ function ResourceReferenceEditor({
useState<ResourceReferenceScope | null>(null);
const [pickerPosition, setPickerPosition] = useState<{
left: number;
bottom: number;
top: number;
width: number;
maxHeight: number;
} | null>(null);
const rootRef = useRef<HTMLDivElement>(null);
@@ -943,18 +944,48 @@ function ResourceReferenceEditor({
const updatePickerPosition = useCallback(() => {
const rect = rootRef.current?.getBoundingClientRect();
if (!rect) return;
const viewportPadding = 12;
const gap = 8;
const width = Math.min(
Math.max(rect.width, 360),
Math.max(280, window.innerWidth - 24),
Math.max(280, window.innerWidth - viewportPadding * 2),
);
const left = Math.min(
Math.max(12, rect.left),
Math.max(12, window.innerWidth - width - 12),
Math.max(viewportPadding, rect.left),
Math.max(viewportPadding, window.innerWidth - width - viewportPadding),
);
/**
* 上边界钳制:面板高度**必须**由输入框上下实际可用的空间决定。
*
* 之前只把底边钉在输入框上方(`bottom: 视口高 - rect.top + 8`)却让高度自由取到 480px,
* 输入框靠上时(居中弹层里的提示词输入、窄屏)整块面板的顶边会被顶出视口——顶部那一排
* 搜索与筛选既看不见也点不到。这里与 `@` 候选菜单同一套口径:先算上下各有多少空间,
* 空间不足就翻到下方,并把高度收在该侧可用空间内,再对 `top` 兜一次底。
*/
const availableAbove = Math.max(0, rect.top - viewportPadding - gap);
const availableBelow = Math.max(
0,
window.innerHeight - rect.bottom - viewportPadding - gap,
);
const openAbove = availableAbove >= 200 || availableAbove >= availableBelow;
const maxHeight = Math.max(
160,
Math.min(480, openAbove ? availableAbove : availableBelow),
);
const top = openAbove
? Math.max(viewportPadding, rect.top - gap - maxHeight)
: Math.min(
Math.max(
viewportPadding,
window.innerHeight - viewportPadding - maxHeight,
),
rect.bottom + gap,
);
setPickerPosition({
left,
bottom: Math.max(12, window.innerHeight - rect.top + 8),
top,
width,
maxHeight,
});
}, []);
@@ -1079,10 +1110,15 @@ function ResourceReferenceEditor({
aria-modal="false"
aria-label="选择素材"
style={{
// 与 `@` 候选菜单同一坐标系(fixed + top + 高度钳制):底边锚点在
// 输入框上方会被顶出视口,只有钉住顶边并收紧高度才能保证整块面板可见。
position: 'fixed',
top: `${pickerPosition.top}px`,
left: `${pickerPosition.left}px`,
bottom: `${pickerPosition.bottom}px`,
right: 'auto',
bottom: 'auto',
width: `${pickerPosition.width}px`,
maxHeight: `${pickerPosition.maxHeight}px`,
}}
>
<header>
@@ -6,26 +6,32 @@ import {
Terminal,
Wrench,
} from 'lucide-react';
import { useEffect, useId, useState } from 'react';
import { useId, useState } from 'react';
import { AgentMessageContent } from '../../../../../packages/shared/src/components/AgentMessageContent';
import { AgentProcessSummary } from '../../../../../packages/shared/src/components/AgentProcessSummary';
import type { DirectChatToolCard } from './directThreadChat';
import {
formatToolCallDuration,
formatTurnDuration,
resolveToolGroupTiming,
toolCallDurationMs,
toolCallGroupSummary,
toolCallInputText,
toolCallRowText,
turnToolCallDurationMs,
turnToolCallTimeLabel,
} from './toolCallGroupPresentation';
import { useLiveNow } from './useLiveNow';
/**
* 一回合的工具调用折叠块(Codex 风格):
* 块头一行汇总 + 该回合总用时 + 结束时间,展开态每行一条工具(行可二级展开看命令 / 文件明细 / 输出)。
* 块头一行汇总 + 本组用时,展开态每行一条工具(行可二级展开看命令 / 文件明细 / 输出)。
* 契约见 `docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md`。
*
* 计时口径(组用时与单条耗时各一套,都是 100ms 粒度、始终一位小数):
* - 本组用时属于**这一组工具**`min(工具开始)` → `max(工具完成)`。同轮另一组还在跑不会让它
* 继续增长;本组工具全部拿到终态就立即冻结。缺开始 / 缺终态边界时隐藏,不借别组或整轮边界。
* 整轮总耗时(用户发送 → 回合终态)只在对话底部渲染一处,不在这里重复。
* - 单条耗时只认该工具的事件级边界(`item.started` → `item.completed`),不看条目展示时间。
*
* 无障碍:块头与每一行都是 `<button aria-expanded>` + `hidden` 控制正文,
* 所以 Tab 可达、Enter/Space 可切换、读屏能读到展开状态与 `aria-label`。
* 文案与耗时规则(纯函数)在 `toolCallGroupPresentation.ts`。
@@ -34,75 +40,53 @@ import {
/** 一回合的工具调用折叠块;空集合不渲染。 */
export function ToolCallGroup({
calls,
userSentAt = 0,
active = false,
className,
}: {
calls: DirectChatToolCard[];
/** 同一回合用户消息的 `updatedAt`;拿不到就传 0,只显示结束时间。 */
userSentAt?: number | null;
/**
* 这个块是否属于**正在跑的回合**。只有正在跑的回合才允许出现"执行中 / 进行中"
* 已结束的回合里若还留着 `running` 快照(进程被杀、completed 事件没到),
* 是数据残留,不该让用户一直看到"执行中"。
* 这个块是否属于**正在跑的回合**:决定本组是否还能用当前时钟增长,以及残留的
* `running` 快照要不要显示"执行中 / 进行中"。已结束的回合里残留的 `running` 快照
* (进程被杀、completed 事件没到)是数据残留,不该让用户一直看到"执行中"。
*/
active?: boolean;
className?: string;
}) {
const [expanded, setExpanded] = useState(false);
const [tick, setTick] = useState(0);
const bodyId = useId();
const hasRunningSnapshot = calls.some((call) => call.status === 'running');
const running = active && hasRunningSnapshot;
// 正在跑时每秒重算一次"已用时",否则这个数字只在收到新事件时才变。
useEffect(() => {
if (!running) {
return undefined;
}
const timer = setInterval(() => setTick((current) => current + 1), 1000);
return () => clearInterval(timer);
}, [running]);
if (calls.length === 0) {
return null;
}
void tick;
const orderedCalls = [...calls].sort(
(left, right) => left.startedAt - right.startedAt,
);
const summary = toolCallGroupSummary(orderedCalls, running);
const startedAt =
orderedCalls.find((call) => call.startedAt > 0)?.startedAt ?? 0;
// 进行中:用"现在 - 最早开始时间"(会随 tick 每秒变);已结束:用快照里的
// min(startedAt) → max(updatedAt),不掺入本地时钟
const totalDurationMs = running
? startedAt > 0
? (() => {
const latestUpdatedAt = Math.max(
...orderedCalls.map((call) => call.updatedAt || 0),
);
// A running snapshot can still carry a monotonic update time. Use
// that delta when available; fall back to wall-clock elapsed time
// for live events that have no update timestamp yet.
return latestUpdatedAt > startedAt
? latestUpdatedAt - startedAt
: Math.max(0, Date.now() - startedAt);
})()
: null
: turnToolCallDurationMs(orderedCalls);
const durationText = formatTurnDuration(totalDurationMs);
const timeLabel = turnToolCallTimeLabel(orderedCalls, userSentAt);
const status = running
? 'running'
: orderedCalls.some((call) => call.status === 'failed')
? 'failed'
: 'completed';
const headLabel = [
summary,
running ? '进行中' : '',
durationText ? `用时 ${durationText}` : '',
const hasRunningSnapshot = calls.some((call) => call.status === 'running');
// 行级"执行中"与 `data-status` 看**这一组**是否还有工具在跑:同轮另一组在跑、或回合还在
// 推理,都不会让已经跑完的组继续标"进行中"。
const toolRunning = active && hasRunningSnapshot;
// 是否需要跟着墙上时钟走:只取决于本组里"还有工具在跑(且回合还在跑)"
const ticking =
active &&
orderedCalls.some(
(call) => call.status === 'running' && call.startedAt > 0,
);
const now = useLiveNow(ticking);
if (calls.length === 0) {
return null;
}
// 本组用时:只看这一组自己的工具边界;本组全完成即冻结。
const timing = resolveToolGroupTiming(orderedCalls, { running: active, now });
const summary = toolCallGroupSummary(orderedCalls);
const hasFailure = orderedCalls.some((call) => call.status === 'failed');
const status = toolRunning ? 'running' : hasFailure ? 'failed' : 'completed';
// 用户口径:`执行了 X 个操作,耗时 XXX`。耗时属于**这一组**(不是整轮),可见文本里用
// 中文逗号连起来,所以分隔符跟着 meta 一起进 DOM。
// 顺序按用户口径:`执行了 X 个操作,耗时 XXX`,运行状态挂到最后,不插在中间。
const headMeta = [
timing.durationText ? `耗时 ${timing.durationText}` : '',
hasFailure ? '有操作失败' : '',
toolRunning ? '进行中' : '',
]
.filter(Boolean)
.join('');
const headLabel = [summary, headMeta].filter(Boolean).join('');
return (
<AgentMessageContent
as="section"
@@ -115,7 +99,8 @@ export function ToolCallGroup({
data-testid="agent-tool-call-group"
aria-label="陶泥儿执行过程"
data-status={status}
data-duration-ms={totalDurationMs ?? ''}
data-has-failure={hasFailure ? 'true' : undefined}
data-duration-ms={timing.durationMs ?? ''}
>
<button
type="button"
@@ -127,26 +112,17 @@ export function ToolCallGroup({
title={headLabel}
onClick={() => setExpanded((current) => !current)}
>
<span className="agent-tool-call-group-icon" aria-hidden="true">
<ToolCallKindIcon kind={orderedCalls[0]?.kind ?? 'other'} />
</span>
<span className="agent-tool-call-group-summary">{summary}</span>
<span className="agent-tool-call-group-meta">
{running ? (
<span className="agent-tool-call-group-running"></span>
) : timeLabel ? (
<span className="agent-tool-call-group-time">{timeLabel}</span>
) : null}
{durationText ? (
<span className="agent-tool-call-group-duration">
{`用时 ${durationText}`}
</span>
) : null}
</span>
<ChevronDown
className="agent-tool-call-group-chevron"
size={14}
aria-hidden="true"
<AgentProcessSummary
icon={<ToolCallKindIcon kind={orderedCalls[0]?.kind ?? 'other'} />}
preview={summary}
meta={headMeta ? `${headMeta}` : ''}
chevron={
<ChevronDown
className="agent-tool-call-group-chevron"
size={14}
aria-hidden="true"
/>
}
/>
</button>
<div
@@ -156,7 +132,7 @@ export function ToolCallGroup({
>
<ul className="agent-tool-call-group-rows">
{orderedCalls.map((call) => (
<ToolCallRow key={call.id} call={call} active={active} />
<ToolCallRow key={call.id} call={call} active={active} now={now} />
))}
</ul>
</div>
@@ -168,14 +144,18 @@ export function ToolCallGroup({
function ToolCallRow({
call,
active,
now,
}: {
call: DirectChatToolCard;
active: boolean;
/** 所属回合的运行时钟;只在"该工具仍在跑"时用得上。 */
now: number;
}) {
const [expanded, setExpanded] = useState(false);
const detailId = useId();
const text = toolCallRowText(call);
const durationMs = toolCallDurationMs(call);
const liveRunning = active && call.status === 'running';
const durationMs = toolCallDurationMs(call, { now, running: liveRunning });
const durationText = formatToolCallDuration(durationMs);
// "执行中"只在**正在跑的回合**里显示;已结束的回合里残留的 running 快照按已完成处理,
// 否则用户会看到一条永远停在"执行中"的记录。
@@ -0,0 +1,73 @@
import remarkGfm from 'remark-gfm';
import remarkParse from 'remark-parse';
import { unified } from 'unified';
/** 折叠态单行预览的字数上限。 */
export const AGENT_PROCESS_PREVIEW_MAX_CHARS = 80;
type PreviewNode = {
type?: string;
value?: unknown;
alt?: unknown;
children?: unknown;
};
/**
* 只从 AST 里取**可见文字**`html` 节点整棵跳过(`<script>` / `<img>` 这类原文不进预览),
* 图片只取 alt,代码取源码值,其它节点递归子节点。链接因此只留字面文字、不带 URL。
*/
function previewText(node: unknown): string {
if (!node || typeof node !== 'object') return '';
const typed = node as PreviewNode;
if (typed.type === 'html') return '';
if (
typed.type === 'text' ||
typed.type === 'inlineCode' ||
typed.type === 'code'
) {
return typeof typed.value === 'string' ? typed.value : '';
}
if (typed.type === 'image' || typed.type === 'imageReference') {
return typeof typed.alt === 'string' ? typed.alt : '';
}
if (!Array.isArray(typed.children)) return '';
const separator = [
'root',
'list',
'listItem',
'blockquote',
'table',
'tableRow',
].includes(typed.type ?? '')
? ' '
: '';
return typed.children.map((child) => previewText(child)).join(separator);
}
/**
* Markdown → 折叠态单行预览(纯函数、纯文本)。
*
* 解析链路与正文同源(`remark-parse` + `remark-gfm`,与 `ChatMarkdownMessage` 的插件一致),
* 但预览只取文字节点:链接只留字面文字(不含 URL / 标题)、强调与 GFM 删除线符号不进预览、
* HTML 原文(`<script>` / `<img>` 等)整棵跳过,因此不需要任何 `dangerouslySetInnerHTML`。
* 空白压成单空格并按上限省略;解析失败退回空预览(展开态仍有完整 Markdown)。
*/
export function agentProcessPreview(
text: string,
maxChars = AGENT_PROCESS_PREVIEW_MAX_CHARS,
): string {
if (!text.trim()) return '';
let preview = '';
try {
const root = unified().use(remarkParse).use(remarkGfm).parse(text);
preview = (root.children ?? [])
.map((node) => previewText(node))
.filter((part) => part.trim().length > 0)
.join(' ');
} catch {
return '';
}
const single = preview.replace(/\s+/g, ' ').trim();
if (single.length <= maxChars) return single;
return `${single.slice(0, maxChars).trimEnd()}`;
}

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