统一对话回合投影并优化工具命令展示
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled

按回合唯一归属渲染正文与工具,修复完成快照冲刷和历史归并

补齐工具输入输出,Windows命令卡片隐藏PowerShell启动器包装

时间显示精确到秒,结束信息右对齐并调整输入框文案与引用按钮间距

同步回归用例与规范文档,保留实机验收待办
This commit is contained in:
2026-09-16 02:30:28 +08:00
parent a86672bb23
commit 09791eb9e4
20 changed files with 1143 additions and 729 deletions
@@ -589,6 +589,7 @@ pub(crate) enum DirectCodexTurnObservation {
AgentMessageSegment {
item_id: String,
accumulated_text: String,
completed: bool,
},
IntermediateText(String),
/// 模型的思考过程(reasoning item 的明文摘要):流式阶段整段替换下发。
@@ -3013,6 +3014,7 @@ impl CodexAppServerConnection {
observer(DirectCodexTurnObservation::AgentMessageSegment {
item_id: item_id.clone(),
accumulated_text: segment_text,
completed: false,
});
}
}
@@ -3148,6 +3150,27 @@ impl CodexAppServerConnection {
}
}
if item_type == "agentMessage" {
// 某些 app-server 实现会在工具开始后停止发送 agentMessage delta
// 但会在 item/completed 携带完整文本。把这份最终快照补进回合流,
// 让流中的文本段不会停在工具前的短前缀。
if completed {
if let (Some(item_id), Some(text)) = (
item.get("id").and_then(serde_json::Value::as_str),
item.get("text")
.and_then(serde_json::Value::as_str)
.filter(|value| !value.trim().is_empty()),
) {
if let Some(observer) = direct_observer.as_deref_mut() {
observer(
DirectCodexTurnObservation::AgentMessageSegment {
item_id: item_id.to_string(),
accumulated_text: text.to_string(),
completed: true,
},
);
}
}
}
if let Some(text) = item
.get("text")
.and_then(serde_json::Value::as_str)
@@ -3185,17 +3208,32 @@ impl CodexAppServerConnection {
}
Some(CodexTurnEvent::Terminal(params)) => {
let turn = params.get("turn").unwrap_or(&params);
if final_text.is_none() {
final_text = turn
.get("items")
.and_then(serde_json::Value::as_array)
.and_then(|items| {
items.iter().rev().find_map(|item| {
(item.get("type")?.as_str()? == "agentMessage")
.then(|| item.get("text")?.as_str().map(str::to_string))
.flatten()
})
});
if let Some(items) = turn.get("items").and_then(serde_json::Value::as_array)
{
for item in items {
if item.get("type").and_then(serde_json::Value::as_str)
!= Some("agentMessage")
{
continue;
}
if let Some(text) = item
.get("text")
.and_then(serde_json::Value::as_str)
.filter(|text| !text.trim().is_empty())
{
final_text = Some(text.to_string());
if let (Some(item_id), Some(observer)) = (
item.get("id").and_then(serde_json::Value::as_str),
direct_observer.as_deref_mut(),
) {
observer(DirectCodexTurnObservation::AgentMessageSegment {
item_id: item_id.to_string(),
accumulated_text: text.to_string(),
completed: true,
});
}
}
}
}
let status = turn
.get("status")
@@ -4505,10 +4505,13 @@ fn persist_collected_direct_tool_calls(root: &Path, collector: &DirectToolCallCo
}
/// 回合流条目落盘:与工具调用同一口径(阻塞线程池 + 项目锁)。
fn spawn_persist_direct_turn_stream_item(root: &Path, item: &DirectTurnStreamItem) {
fn spawn_persist_direct_turn_stream_item(
root: &Path,
item: &DirectTurnStreamItem,
) -> tauri::async_runtime::JoinHandle<Result<(), String>> {
let root = root.to_path_buf();
let item = item.clone();
tauri::async_runtime::spawn_blocking(move || upsert_direct_turn_stream_item_at(&root, &item));
tauri::async_runtime::spawn_blocking(move || upsert_direct_turn_stream_item_at(&root, &item))
}
/// 文本段落盘/下发的节流间隔:文本段是"整段累计 + 原地替换",不需要逐 delta 落盘。
@@ -4530,6 +4533,7 @@ struct DirectTurnStreamWriter {
turn_id: String,
seq_by_id: BTreeMap<String, u64>,
next_seq: u64,
last_updated_at: u64,
pending_text: Option<DirectTurnStreamPendingText>,
}
@@ -4539,6 +4543,7 @@ impl DirectTurnStreamWriter {
turn_id,
seq_by_id: BTreeMap::new(),
next_seq: 0,
last_updated_at: 0,
pending_text: None,
}
}
@@ -4553,60 +4558,54 @@ impl DirectTurnStreamWriter {
self.next_seq
}
/// 文本段推进。返回需要落盘 + 下发的那份快照(节流窗口内返回 `None`)
///
/// 段身份(`item_id`)变化时先把上一段的收尾快照交出去:上一段最后一段文字不能丢。
/// 按 item 身份更新,段切换必须同时交出旧段尾快照与新段首快照
fn push_text(
&mut self,
root: &Path,
item_id: &str,
visible_text: &str,
now_ms: u64,
) -> Option<DirectTurnStreamItem> {
completed: bool,
) -> Vec<DirectTurnStreamItem> {
let now = std::time::Instant::now();
let mut flushed = None;
let mut snapshots = Vec::new();
self.last_updated_at = now_ms.max(self.last_updated_at.saturating_add(1));
if self
.pending_text
.as_ref()
.is_some_and(|pending| pending.item_id != item_id)
{
flushed = self.take_pending_snapshot();
snapshots.extend(self.take_pending_snapshot());
}
match self.pending_text.as_mut() {
Some(pending) if pending.item_id == item_id => {
pending.item.text = Some(sanitize_stream_text(root, visible_text));
pending.item.updated_at = now_ms.max(pending.item.updated_at);
if now.duration_since(pending.last_flush).as_millis()
if let Some(pending) = self.pending_text.as_mut() {
pending.item.text = Some(sanitize_stream_text(root, visible_text));
pending.item.updated_at = self.last_updated_at;
if completed
|| now.duration_since(pending.last_flush).as_millis()
>= DIRECT_TURN_STREAM_TEXT_THROTTLE_MS
{
pending.last_flush = now;
return Some(pending.item.clone());
}
}
_ => {
let seq = self.seq_for(&direct_turn_stream_text_item_id(&self.turn_id, item_id));
let item = direct_turn_stream_text_item(
root,
&self.turn_id,
item_id,
visible_text,
seq,
now_ms,
now_ms,
);
self.pending_text = Some(DirectTurnStreamPendingText {
item_id: item_id.to_string(),
item,
last_flush: now,
});
// 新段一出现就立刻落盘 + 下发:位置由这一刻钉死。
return self
.pending_text
.as_ref()
.map(|pending| pending.item.clone());
{
pending.last_flush = now;
snapshots.push(pending.item.clone());
}
} else {
let seq = self.seq_for(&direct_turn_stream_text_item_id(&self.turn_id, item_id));
let item = direct_turn_stream_text_item(
root,
&self.turn_id,
item_id,
visible_text,
seq,
now_ms,
self.last_updated_at,
);
snapshots.push(item.clone());
self.pending_text = Some(DirectTurnStreamPendingText {
item_id: item_id.to_string(),
item,
last_flush: now,
});
}
flushed
snapshots
}
/// 取出当前段的收尾快照(段结束 / 回合结束时调用),不再持有它。
@@ -4658,7 +4657,8 @@ async fn run_direct_game_creator_turn_inner(
let turn_tool_calls = Arc::clone(&tool_calls);
// 回合流:文本段与工具按**出现顺序**各占一行,位置(seq)在首次出现时钉死。
let mut stream_writer = DirectTurnStreamWriter::new(client_turn_id.clone());
let mut observer = move |observation: DirectCodexTurnObservation| {
let mut stream_writes = Vec::new();
let mut observer = |observation: DirectCodexTurnObservation| {
let status = direct_codex_observation_status(&observation, stream_enabled);
match observation {
DirectCodexTurnObservation::AccumulatedText(accumulated_text) => {
@@ -4672,23 +4672,26 @@ async fn run_direct_game_creator_turn_inner(
DirectCodexTurnObservation::AgentMessageSegment {
item_id,
accumulated_text,
completed,
} => {
// 可见文本段:同一 item 的后续 delta 就地增长,item 变了才新起一段。
let Some(visible_text) = project_direct_codex_visible_text(&accumulated_text)
else {
return;
};
let Some(item) = stream_writer.push_text(
let items = stream_writer.push_text(
&turn_root,
&item_id,
&visible_text,
direct_tool_call_now_ms(),
) else {
return;
};
// 事件与落盘共用同一份快照:前端的顺序真相与文件里的顺序真相一致。
spawn_persist_direct_turn_stream_item(&turn_root, &item);
emitter.emit_with_stream_items(status, None, None, None, vec![item]);
completed,
);
for item in &items {
stream_writes.push(spawn_persist_direct_turn_stream_item(&turn_root, item));
}
if !items.is_empty() {
emitter.emit_with_stream_items(status, None, None, None, items);
}
}
DirectCodexTurnObservation::IntermediateText(intermediate_text) => {
let visible_text = if stream_enabled
@@ -4710,23 +4713,31 @@ async fn run_direct_game_creator_turn_inner(
// streaming 已被"用户可见正文"占用。
emitter.emit_with_reasoning("running", None, None, None, Some(reasoning));
}
DirectCodexTurnObservation::ToolCall(tool_call) => {
// 同一工具调用会被观察两次:`item/started`running)与 `item/completed`
// (终态)。这里**只在状态真的变化时**才再收集与下发一次,既能带上终态、
// 又不会在每个 heartbeat 重发同一份快照(前端按 id 幂等合并,不会多出卡片)。
//
// 曾经这里用"每个 id 只发一次"去重,结果终态观察被直接丢掉:工具调用永远
// 停在 running(实机表现为"命令都结束了还显示执行中")。
DirectCodexTurnObservation::ToolCall(mut tool_call) => {
// 同一工具的开始、完成和详情补全共用一份单调快照。
{
let collected = lock_direct_tool_call_collector(&turn_tool_calls);
let existing = collected
.iter()
.find(|existing| existing.id == tool_call.id);
if existing.is_some_and(|call| {
call.status != "running" && tool_call.status == "running"
}) {
return;
}
if !super::direct_tool_calls::direct_tool_call_status_changed(
existing, &tool_call,
) {
return;
}
if let Some(existing) = existing {
tool_call.updated_at = tool_call
.updated_at
.max(existing.updated_at.saturating_add(1));
tool_call = super::direct_tool_calls::merge_tool_call_snapshot(
existing, &tool_call,
);
}
}
{
let mut collected = lock_direct_tool_call_collector(&turn_tool_calls);
@@ -4734,22 +4745,27 @@ async fn run_direct_game_creator_turn_inner(
collected.push(tool_call.clone());
}
// 回合流:工具是**普通元素**,位置在文本段之后(或与相邻工具成块)。
let stream_item =
stream_writer.push_tool(&tool_call, direct_tool_call_now_ms());
spawn_persist_direct_turn_stream_item(&turn_root, &stream_item);
let mut items = stream_writer
.take_pending_snapshot()
.into_iter()
.collect::<Vec<_>>();
items.push(stream_writer.push_tool(&tool_call, direct_tool_call_now_ms()));
for item in &items {
stream_writes.push(spawn_persist_direct_turn_stream_item(&turn_root, item));
}
emitter.emit_with_stream_items(
status,
None,
None,
Some(vec![tool_call.clone()]),
vec![stream_item],
items,
);
// 落盘"最新的那一份":started 让卡片刷新后立刻出现,终态覆盖同一行。
spawn_persist_direct_tool_call(&turn_root, &tool_call);
}
}
};
direct_game_creator_codex_chat_at_with_optional_observer(
let reply_result = direct_game_creator_codex_chat_at_with_optional_observer(
root,
system_prompt,
prompt.to_string(),
@@ -4758,7 +4774,19 @@ async fn run_direct_game_creator_turn_inner(
audit,
direct_user_item.clone(),
)
.await
.await;
drop(observer);
if let Some(item) = stream_writer.take_pending_snapshot() {
stream_writes.push(spawn_persist_direct_turn_stream_item(&turn_root, &item));
emitter.emit_with_stream_items("streaming", None, None, None, vec![item]);
}
// finalize 必须看见这一轮全部快照,不能与 fire-and-forget 写任务竞争。
for write in stream_writes {
if !matches!(write.await, Ok(Ok(()))) {
app_log!("[turn-stream] 回合快照持久化失败");
}
}
reply_result
} else {
direct_game_creator_codex_chat_at_with_optional_observer(
root,
@@ -4782,8 +4810,7 @@ async fn run_direct_game_creator_turn_inner(
)
})?;
if let Some(emitter) = turn_emitter {
// 最终回复落到本回合最后一条文本段上(原地更新,不新起一段),并随事件下发:
// 前端据此把最后一段替换成最终可见回复,流式尾巴与最终回复不会重复。
// 已有 item 文本由完成事件负责;只有完全没有 item 文本才补最终回复。
let finalized =
finalize_direct_turn_stream_reply_at(root, emitter.turn_id(), &visible_reply)
.ok()
@@ -4820,6 +4847,38 @@ async fn run_direct_game_creator_turn_inner(
Ok(visible_reply)
}
#[cfg(test)]
mod direct_turn_stream_writer_tests {
use super::*;
#[test]
fn item_switch_returns_previous_tail_and_next_head() {
let mut writer = DirectTurnStreamWriter::new("turn".into());
let root = Path::new(".");
writer.push_text(root, "a", "前缀", 1000, false);
writer.push_text(root, "a", "完整正文", 1000, false);
let snapshots = writer.push_text(root, "b", "第二段", 1000, false);
assert_eq!(snapshots.len(), 2);
assert_eq!(snapshots[0].text.as_deref(), Some("完整正文"));
assert_eq!(snapshots[0].seq, 1);
assert_eq!(snapshots[1].seq, 2);
assert!(snapshots[1].updated_at > snapshots[0].updated_at);
}
#[test]
fn completed_snapshot_bypasses_throttle_and_keeps_item_position() {
let mut writer = DirectTurnStreamWriter::new("turn".into());
let root = Path::new(".");
let first = writer.push_text(root, "a", "前缀", 1000, false);
let completed = writer.push_text(root, "a", "完整正文", 1000, true);
assert_eq!(completed.len(), 1);
assert_eq!(completed[0].id, first[0].id);
assert_eq!(completed[0].seq, first[0].seq);
assert!(completed[0].updated_at > first[0].updated_at);
assert_eq!(completed[0].text.as_deref(), Some("完整正文"));
}
}
/// Default product path: one user message becomes one turn on the same
/// project-bound Codex app-server thread. The client does not classify the
/// intent or perform hidden art, preview, repair, or another LLM workflow. If
@@ -230,9 +230,14 @@ fn first_line_bounded(value: &str, max_chars: usize) -> String {
bounded_chars(first_line, max_chars)
}
fn optional_bounded_line(value: Option<&str>, max_chars: usize) -> Option<String> {
let value = value?.trim();
(!value.is_empty()).then(|| bounded_chars(value, max_chars))
/// 把 app-server 中可能是字符串或 JSON 对象的工具详情统一转成可读文本。
fn direct_tool_call_value_text(value: &Value) -> Option<String> {
match value {
Value::String(text) => (!text.trim().is_empty()).then(|| text.trim().to_string()),
Value::Null => None,
// 调用方先脱敏再截断,不能在这里截断掉敏感字段的语法边界。
_ => serde_json::to_string_pretty(value).ok(),
}
}
fn tool_call_kind(item_type: &str) -> Option<&'static str> {
@@ -325,15 +330,17 @@ fn direct_tool_call_status(item: &Value, completed: bool) -> &'static str {
}
}
/// 同一 id 的两次观察(`item/started` / `item/completed`)是否带来了状态变化
///
/// 只有状态变化时才需要再收集、再下发一次:既避免同一份快照在每个心跳重复下发,
/// 又不会像"每个 id 只发一次"那样把终态丢掉(历史 bug:命令都结束了卡片仍显示"执行中")。
/// 状态或可见详情变化才下发;同状态的输入/输出补全也属于更新
pub(crate) fn direct_tool_call_status_changed(
existing: Option<&DirectToolCall>,
incoming: &DirectToolCall,
) -> bool {
!existing.is_some_and(|current| current.status == incoming.status)
!existing.is_some_and(|current| {
current.status == incoming.status
&& current.detail == incoming.detail
&& current.title == incoming.title
&& current.summary == incoming.summary
})
}
/// 把一条 Codex item 投影成工具调用条目。非工具类 item 返回 `None`。
@@ -382,14 +389,17 @@ pub(crate) fn direct_tool_call_from_item(
.map(str::trim)
.filter(|command| !command.is_empty())
.map(|command| sanitize_detail_text(root, command))
.map(|command| bounded_chars(&command, DIRECT_TOOL_CALL_DETAIL_MAX_CHARS));
let output = item
.get("aggregatedOutput")
.or_else(|| item.get("output"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|output| !output.is_empty())
.map(|output| sanitize_detail_text(root, output))
.map(|command| bounded_chars(&command, DIRECT_TOOL_CALL_DETAIL_MAX_CHARS))
.or_else(|| {
item.get("arguments")
.and_then(direct_tool_call_value_text)
.map(|arguments| sanitize_detail_text(root, &arguments))
.map(|arguments| bounded_chars(&arguments, DIRECT_TOOL_CALL_DETAIL_MAX_CHARS))
});
let output = ["aggregatedOutput", "output", "result", "error"]
.iter()
.find_map(|key| item.get(key).and_then(direct_tool_call_value_text))
.map(|output| sanitize_detail_text(root, &output))
.map(|output| bounded_chars(&output, DIRECT_TOOL_CALL_DETAIL_MAX_CHARS));
// `fileChange` 的路径先脱敏成"项目内相对路径":绝对路径会被抹成 `<absolute-path>`
// 相对路径原样保留(契约要求 detail.changes[].path 用项目相对路径)。
@@ -408,11 +418,15 @@ pub(crate) fn direct_tool_call_from_item(
.filter(|tool| !tool.is_empty())
.map(|tool| sanitize_detail_text(root, tool));
// `summary` 会落到卡片与落盘文件,它的兜底来源同样必须脱敏。
let summary_source = command
.as_deref()
.or_else(|| changes.first().map(|change| change.path.as_str()))
.or(tool.as_deref())
.unwrap_or_default();
let summary_source = (if kind == "mcp_tool" {
tool.as_deref()
} else {
None
})
.or(command.as_deref())
.or_else(|| changes.first().map(|change| change.path.as_str()))
.or(tool.as_deref())
.unwrap_or_default();
let summary = first_line_bounded(summary_source, DIRECT_TOOL_CALL_SUMMARY_MAX_CHARS);
Some(DirectToolCall {
@@ -538,7 +552,7 @@ fn status_certainty(status: &str) -> u8 {
/// 「回合末整批落盘」与「逐条快照落盘(spawn_blocking)」两条路径竞争时,后到的
/// 旧快照不能把已经 `completed` / `failed` 的卡片打回 `running`。
/// - `updatedAt` 相同时终态优先,避免同一毫秒内的旧快照回退状态。
fn merge_tool_call_snapshot(
pub(crate) fn merge_tool_call_snapshot(
existing: &DirectToolCall,
incoming: &DirectToolCall,
) -> DirectToolCall {
@@ -550,6 +564,20 @@ fn merge_tool_call_snapshot(
} else {
existing.clone()
};
if merged.detail.command.is_none() {
merged.detail.command = existing
.detail
.command
.clone()
.or(incoming.detail.command.clone());
}
if merged.detail.output.is_none() {
merged.detail.output = existing
.detail
.output
.clone()
.or(incoming.detail.output.clone());
}
merged.started_at = [merged.started_at, existing.started_at, incoming.started_at]
.into_iter()
.filter(|started_at| *started_at > 0)
@@ -8,8 +8,7 @@
//! 文本追加 / 工具状态变化)只改内容不改 `seq`。因此并发落盘的先后顺序不会让"新工具插到
//! 旧文本前面"——渲染顺序只由 `seq` 决定。
//!
//! 为什么不复用 `project.jsonl`:那条链路一个回合只投影一条 assistant 消息(整段回复),
//! 中途的文本段没有独立记录,装不下"文本段与工具交替"的顺序。
//! `project.jsonl` 保留原始消息;本流补充文本与工具交替的 item 顺序,不能重复展示两份正文。
use crate::agent::sanitize_detail_text;
use crate::config::{prepare_game_creator_private_path_for_read, write_game_creator_private_file};
@@ -59,6 +58,44 @@ pub(crate) struct DirectTurnStreamItem {
pub(crate) updated_at: u64,
}
#[cfg(test)]
mod snapshot_tests {
use super::*;
fn text(
turn: &str,
id: &str,
seq: u64,
at: u64,
updated: u64,
text: &str,
) -> DirectTurnStreamItem {
direct_turn_stream_text_item(Path::new("."), turn, id, text, seq, at, updated)
}
#[test]
fn late_older_snapshot_cannot_undo_completed_text_or_position() {
let complete = text("turn", "item", 1, 1000, 1002, "正文");
let late = text("turn", "item", 9, 1001, 1001, "更长但已经过期的草稿");
let merged = normalize_stream_items(vec![complete, late]);
assert_eq!(merged.len(), 1);
assert_eq!(merged[0].text.as_deref(), Some("正文"));
assert_eq!(merged[0].seq, 1);
assert_eq!(merged[0].at, 1000);
}
#[test]
fn retention_does_not_treat_new_turn_seq_one_as_oldest() {
let mut snapshots = (1..=DIRECT_TURN_STREAM_LIMIT)
.map(|seq| text("old", &seq.to_string(), seq as u64, 1000, 1000, ""))
.collect::<Vec<_>>();
snapshots.push(text("new", "one", 1, 2000, 2000, ""));
let merged = normalize_stream_items(snapshots);
assert_eq!(merged.len(), DIRECT_TURN_STREAM_LIMIT);
assert_eq!(merged.last().unwrap().turn_id, "new");
}
}
impl DirectTurnStreamItem {
fn order_key(&self) -> (u64, u64, &str) {
(self.seq, self.at, self.id.as_str())
@@ -211,10 +248,8 @@ fn merge_stream_snapshot(
.map(Iterator::count)
.unwrap_or_default()
};
// 文本段按"更长优先":同一个 item 的文本只会增长,而快照的 updated_at 未必单调递增;
// 只认 updated_at 更新会把后到的完整文本丢掉(实机表现:正文只剩前缀"我来")。
// writer 保证更新时间单调;完成快照可以纠正正文,旧快照不能靠更长抢回所有权。
let take_incoming = incoming.updated_at > existing.updated_at
|| (incoming.kind == "text" && text_len(incoming) > text_len(existing))
|| (incoming.updated_at == existing.updated_at && text_len(incoming) > text_len(existing));
let mut merged = existing.clone();
if take_incoming {
@@ -233,7 +268,7 @@ fn merge_stream_snapshot(
merged
}
/// 按 id 归并,再`seq` 正序裁剪到最近 `DIRECT_TURN_STREAM_LIMIT` 条
/// 按身份归并;跨回合按起点,回合内按 seq,不能用局部 seq 判断全局新旧
fn normalize_stream_items(items: Vec<DirectTurnStreamItem>) -> Vec<DirectTurnStreamItem> {
let mut by_id: BTreeMap<String, DirectTurnStreamItem> = BTreeMap::new();
for item in items {
@@ -244,7 +279,20 @@ fn normalize_stream_items(items: Vec<DirectTurnStreamItem>) -> Vec<DirectTurnStr
by_id.insert(merged.id.clone(), merged);
}
let mut normalized = by_id.into_values().collect::<Vec<_>>();
normalized.sort_by(|left, right| left.order_key().cmp(&right.order_key()));
let mut turn_starts = BTreeMap::<String, u64>::new();
for item in &normalized {
turn_starts
.entry(item.turn_id.clone())
.and_modify(|at| *at = (*at).min(item.at))
.or_insert(item.at);
}
normalized.sort_by(|left, right| {
(turn_starts[&left.turn_id], &left.turn_id, left.order_key()).cmp(&(
turn_starts[&right.turn_id],
&right.turn_id,
right.order_key(),
))
});
if normalized.len() > DIRECT_TURN_STREAM_LIMIT {
normalized.drain(..normalized.len() - DIRECT_TURN_STREAM_LIMIT);
}
@@ -286,7 +334,7 @@ pub(crate) fn upsert_direct_turn_stream_item_at(
) -> Result<(), String> {
enforce_project_permission_policy(root, "conversation.write")?;
with_locked_stream_items(root, |items| {
items.retain(|existing| existing.id != item.id);
// normalize_stream_items 在锁内归并全部版本;不得提前删除比较基准。
items.push(item.clone());
})
}
@@ -345,14 +393,7 @@ pub(crate) fn append_direct_turn_stream_text_at(
})
}
/// 回合结束:把**可见回复**落到本回合最后一条文本段上
///
/// - 本回合已有文本段(流式逐段落的那些):原地把最后一段替换成最终可见回复,
/// 不新起一段——否则最终回复会和最后一段重复。
/// - 本回合没有文本段(非流式、或整轮没有文本):追加一段,位置排在最后。
///
/// `visible_reply` 由调用方先做可见性投影(去思考块、去空)。返回被写入的那一条,
/// 调用方用它下发同一份快照。
/// 没有任何 item 文本时补最终回复;已有 item 由完成事件负责,不能猜测覆盖某一段
pub(crate) fn finalize_direct_turn_stream_reply_at(
root: &Path,
turn_id: &str,
@@ -369,36 +410,30 @@ pub(crate) fn finalize_direct_turn_stream_reply_at(
enforce_project_permission_policy(root, "conversation.write")?;
let now = crate::agent::direct_tool_call_now_ms();
with_locked_stream_items(root, |items| {
let last_text_id = items
if items
.iter()
.filter(|item| item.turn_id == turn_id && item.kind == DIRECT_TURN_STREAM_KIND_TEXT)
.max_by(|left, right| left.order_key().cmp(&right.order_key()))
.map(|item| item.id.clone());
match last_text_id {
Some(id) => {
let item = items
.iter_mut()
.find(|item| item.id == id)
.expect("最后一条文本段必须还在集合里");
// 位置不动:只替换文本与 updatedAt(原地更新,不重排)。
item.text = Some(sanitize_stream_text(root, visible_reply));
item.updated_at = now.max(item.updated_at);
Some(item.clone())
}
None => {
let next_seq = items.iter().map(|item| item.seq).max().unwrap_or(0) + 1;
let item = direct_turn_stream_text_item(
root,
turn_id,
DIRECT_TURN_STREAM_FINAL_ITEM_ID,
visible_reply,
next_seq,
now,
now,
);
items.push(item.clone());
Some(item)
}
.any(|item| item.turn_id == turn_id && item.kind == DIRECT_TURN_STREAM_KIND_TEXT)
{
None
} else {
let next_seq = items
.iter()
.filter(|item| item.turn_id == turn_id)
.map(|item| item.seq)
.max()
.unwrap_or(0)
+ 1;
let item = direct_turn_stream_text_item(
root,
turn_id,
DIRECT_TURN_STREAM_FINAL_ITEM_ID,
visible_reply,
next_seq,
now,
now,
);
items.push(item.clone());
Some(item)
}
})
}
+61 -90
View File
@@ -499,29 +499,6 @@ function directCodexConversationMessageId(
return `${DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX}${turnId}:${role}`;
}
/** 从 `direct-codex:<turnId>:assistant` 反解回合 id;不是这个形状就返回 `null`。 */
/**
* direct-codex id id`direct-codex:<turnId>:user` / `:assistant`
*
* assistant Codex id null
* "全部解不出来"
*/
function directCodexTurnIdFromConversationMessageId(messageId: string) {
if (!messageId.startsWith(DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX)) {
return null;
}
const rest = messageId.slice(
DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX.length,
);
for (const suffix of [':user', ':assistant']) {
if (rest.endsWith(suffix)) {
const turnId = rest.slice(0, -suffix.length).trim();
return turnId || null;
}
}
return rest.trim() || null;
}
export const MAX_CHAT_COMPOSER_ATTACHMENTS = 8;
/**
@@ -569,17 +546,15 @@ function mergeTurnStreamItems(
// 内容只在更新(或同刻更长)的快照上替换;`seq` 取最早,位置不许回退。
const textLength = (value: TurnStreamItem) =>
value.kind === 'text' ? (value.text?.length ?? 0) : 0;
// 文本段按"更长优先":同一个 item 的文本只会增长,而快照的 updatedAt 未必单调递增;
// 只认 updatedAt 更新会把后到的完整文本丢掉(实机表现:正文只剩前缀"我来")。
// writer 更新时间单调;完成快照允许纠正正文,迟到旧快照不能覆盖。
const takeIncoming =
normalized.updatedAt > previous.updatedAt ||
(normalized.kind === 'text' &&
textLength(normalized) > textLength(previous)) ||
(normalized.updatedAt === previous.updatedAt &&
textLength(normalized) > textLength(previous));
byId.set(id, {
...(takeIncoming ? normalized : previous),
id,
updatedAt: Math.max(previous.updatedAt, normalized.updatedAt),
seq: Math.min(previous.seq, normalized.seq),
at:
previous.at > 0 && normalized.at > 0
@@ -827,6 +802,7 @@ export function App({
useEffect(() => {
if (supervisorChatOnly) return;
const nextProjectPath = localProject?.projectPath ?? null;
ensureDirectTimelineProject(nextProjectPath);
const previousProjectPath = localProjectPathRef.current;
localProjectPathRef.current = nextProjectPath;
// 未绑定项目时无需触发插件宿主;这也避免启动空首页时产生无意义的 Tauri 调用。
@@ -1004,6 +980,16 @@ export function App({
GameCreatorDirectToolCall[]
>([]);
const directToolCallsRef = useRef<GameCreatorDirectToolCall[]>([]);
const directTimelineProjectPathRef = useRef<string | null>(null);
function ensureDirectTimelineProject(project: string | null) {
if (directTimelineProjectPathRef.current === project) return;
directTimelineProjectPathRef.current = project;
directToolCallsRef.current = [];
directTurnStreamRef.current = [];
setDirectToolCalls([]);
setDirectTurnStream([]);
}
/**
* id `completed` `running`
* id id
@@ -1020,7 +1006,9 @@ export function App({
if (!id) {
continue;
}
const existingIndex = merged.findIndex((existing) => existing.id === id);
const existingIndex = merged.findIndex(
(existing) => existing.id === id && existing.turnId === call.turnId,
);
const normalized: GameCreatorDirectToolCall = {
...call,
id,
@@ -1028,6 +1016,18 @@ export function App({
};
// 起点时间取更早的那个:`completed` 事件不一定带 startedAt。
const existing = existingIndex >= 0 ? merged[existingIndex] : undefined;
if (existing) {
if (
existing.updatedAt > normalized.updatedAt ||
(existing.status !== 'running' && normalized.status === 'running')
)
continue;
normalized.detail = {
...normalized.detail,
command: normalized.detail.command ?? existing.detail.command,
output: normalized.detail.output ?? existing.detail.output,
};
}
if (
existing &&
existing.startedAt > 0 &&
@@ -1183,23 +1183,6 @@ export function App({
watchRecoveredDirectCodexTurn(projectPath, clientTurnId);
}
/** 工具调用卡片按项目维度作废:换项目 / 重开历史时整体替换,避免串项目。 */
function replaceDirectToolCalls(next: readonly GameCreatorDirectToolCall[]) {
const normalized = next
.filter((call) => Boolean(call.id?.trim()))
.map((call) => ({
...call,
id: call.id.trim(),
detail: call.detail ?? { changes: [] },
}))
.sort(
(left, right) =>
left.startedAt - right.startedAt || left.id.localeCompare(right.id),
);
directToolCallsRef.current = normalized;
setDirectToolCalls(normalized);
}
// 回合流(文本段 + 工具按**出现顺序**交替):实时增量与回读历史共用一份状态,
// 渲染顺序只由条目的 `seq` 决定,界面不再按文本长度 / 标点 / 时间窗猜切点。
const [directTurnStream, setDirectTurnStream] = useState<TurnStreamItem[]>(
@@ -1217,15 +1200,6 @@ export function App({
setDirectTurnStream(merged);
}
/** 回合流按项目维度作废:换项目 / 重开历史时整体替换,避免串项目。 */
function replaceTurnStreamItems(next: readonly TurnStreamItem[]) {
const normalized = sortTurnStreamItems(
next.filter((item) => Boolean(item.id?.trim())),
);
directTurnStreamRef.current = normalized;
setDirectTurnStream(normalized);
}
function clearDirectCodexTransientReply(projectPath: string, turnId: string) {
const activeTurn = activeDirectCodexTurnRef.current;
if (
@@ -2310,6 +2284,7 @@ export function App({
: current,
);
}
ensureDirectTimelineProject(payload.projectPath);
// 工具调用增量:字段可选,老事件(undefined)走原路径,行为不变。
if (payload.toolCalls?.length) {
applyDirectToolCalls(
@@ -2444,7 +2419,7 @@ export function App({
activeDirectCodexTurnRef.current = {
projectPath,
turnId: activeTurn.turnId,
lastSequence: 0,
lastSequence: -1,
receivedDirectUpdate: true,
};
setDirectCodexProcessKey(`${projectPath}\\u0000${activeTurn.turnId}`);
@@ -2471,7 +2446,8 @@ export function App({
activeDirectCodexTurnRef.current = {
projectPath,
turnId: state.turnId,
lastSequence: state.lastSeq,
// Thread Manager 的 seq 与回合展示事件的 sequence 是两个独立序列。
lastSequence: -1,
receivedDirectUpdate: true,
};
setDirectCodexProcessKey(`${projectPath}\u0000${state.turnId}`);
@@ -4160,7 +4136,6 @@ export function App({
'read_direct_tool_calls',
{ projectPath: nextProjectPath },
).catch(() => []);
replaceDirectToolCalls(persistedToolCalls);
// 回合流(顺序真相)走独立历史文件(`turn-stream.jsonl`)。与工具调用同一处:必须在
// 读完项目对话之后、任何提前 return 之前回读。缺命令(老客户端)/ 缺文件 / 读取失败
// 都只是"这个回合没有流",界面回退到原来的渲染,不能因此把整个打开流程判失败。
@@ -4168,7 +4143,15 @@ export function App({
'read_direct_turn_stream',
{ projectPath: nextProjectPath },
).catch(() => []);
replaceTurnStreamItems(persistedTurnStream);
if (
projectSupervisorHistoryLoadVersionRef.current !== loadVersion ||
localProjectPathRef.current !== nextProjectPath
)
return;
ensureDirectTimelineProject(nextProjectPath);
// 回读可能与实时事件交错:按同一身份合并,不能用旧磁盘快照覆盖实时状态。
applyDirectToolCalls(persistedToolCalls);
applyTurnStreamItems(persistedTurnStream);
// 重进会话时 Rust 侧可能仍登记着上一条 Direct 回合。不接管的话界面既不显示
// 过程卡也不给终止入口,用户再发消息只会被守卫拒绝("已有另一条回合正在运行")。
await restoreRunningDirectCodexTurn(nextProjectPath);
@@ -7144,17 +7127,21 @@ export function App({
const appendDirectAssistantMessage = (
current: ChatMessage[],
text: string,
failed = false,
): ChatMessage[] => {
const messageId = failed
? `direct-codex:${clientTurnId}:failure`
: directAssistantMessageId;
const nextMessage: ChatMessage = {
role: 'assistant',
text,
runtimeOwned: true,
messageId: directAssistantMessageId,
messageId,
updatedAt: Date.now(),
};
const withUser = appendDirectUserMessageIfMissing(current);
const existingIndex = withUser.findIndex(
(message) => message.messageId === directAssistantMessageId,
(message) => message.messageId === messageId,
);
if (existingIndex < 0) {
return [...withUser, nextMessage];
@@ -7259,7 +7246,7 @@ export function App({
setProjectSupervisorRuntimeError('');
setChatComposerNotice('已终止本次回合');
setMessages((current) =>
appendDirectAssistantMessage(current, '已终止本次回合。'),
appendDirectAssistantMessage(current, '已终止本次回合。', true),
);
return;
}
@@ -7295,7 +7282,7 @@ export function App({
setDirectCodexProgress('正在记录失败原因');
setProjectSupervisorRuntimeError(visibleMessage);
setMessages((current) =>
appendDirectAssistantMessage(current, visibleMessage),
appendDirectAssistantMessage(current, visibleMessage, true),
);
}
} finally {
@@ -12449,32 +12436,6 @@ export function App({
0,
messages.length - visibleMessages.length,
);
// 工具调用卡片只保留「当前消息列表里确实有这个回合」的那些:实时回合一进来就能挂上,
// 历史回合只有对应的 assistant 消息还在列表里才渲染,避免旧卡片漂在列表尾部。
const visibleTurnIds = new Set(
messages
.map((message) => message.messageId)
.filter((messageId): messageId is string => Boolean(messageId))
.map(directCodexTurnIdFromConversationMessageId)
.filter((turnId): turnId is string => Boolean(turnId)),
);
// 老历史里 assistant 消息是 Codex 原始 id,一个回合都解不出来:此时**不过滤**,
// 否则工具卡片与回合流会被整批丢掉(实机表现:重进会话后工具与流全部消失)。
const keepAllDirectCodexTurns = visibleTurnIds.size === 0;
const visibleToolCalls = directToolCalls.filter(
(call) =>
keepAllDirectCodexTurns ||
visibleTurnIds.has(call.turnId) ||
call.turnId === activeDirectCodexTurnRef.current?.turnId,
);
// 回合流只保留「当前消息列表里确实有这个回合」的那些,外加正在跑的回合;
// 没有流条目的回合由视图自己回退到原来的渲染。
const visibleTurnStreamItems = directTurnStream.filter(
(item) =>
keepAllDirectCodexTurns ||
visibleTurnIds.has(item.turnId) ||
item.turnId === activeDirectCodexTurnRef.current?.turnId,
);
const hasEarlierConversationMessages =
hiddenConversationCount > 0 ||
(directCodexProductRuntime && directHistoryHasMore);
@@ -13033,8 +12994,18 @@ export function App({
}
pendingCommand={directCodexProductRuntime ? pendingCommand : null}
projectPath={localProject?.projectPath ?? projectPath}
toolCalls={visibleToolCalls}
turnStreamItems={visibleTurnStreamItems}
toolCalls={
directTimelineProjectPathRef.current === localProject?.projectPath
? directToolCalls
: []
}
turnStreamItems={
directTimelineProjectPathRef.current === localProject?.projectPath
? directTurnStream
: []
}
conversationMessages={messages}
hasUnloadedHistory={directHistoryHasMore}
activeTurnId={
directCodexProductRuntime
? (activeDirectCodexTurnRef.current?.turnId ?? null)
File diff suppressed because it is too large Load Diff
@@ -331,9 +331,7 @@ export function SupervisorChatOnlyView({
value={chatInput}
references={chatReferences}
placeholder={
directCodex
? '告诉陶泥儿接下来要做什么,或输入 @ 选择资源'
: '给项目总控 Agent 发消息,或输入 @ 选择资源'
directCodex ? '描述你的想法' : '给项目总控 Agent 发消息'
}
onChange={onChatInputChange}
/>
@@ -14,6 +14,7 @@ import {
formatTurnDuration,
toolCallDurationMs,
toolCallGroupSummary,
toolCallInputText,
toolCallRowText,
turnToolCallDurationMs,
turnToolCallTimeLabel,
@@ -178,7 +179,7 @@ function ToolCallRow({
.filter(Boolean)
.join('');
const changes = call.detail.changes ?? [];
const detailCommand = call.detail.command?.trim() ?? '';
const detailCommand = toolCallInputText(call);
const detailOutput = call.detail.output?.trim() ?? '';
const hasDetail =
Boolean(detailCommand) || Boolean(detailOutput) || changes.length > 0;
@@ -0,0 +1,230 @@
import type {
ChatMessage,
GameCreatorDirectToolCall,
TurnStreamItem,
} from '../../app/types';
import { projectSupervisorChatMessageText } from '../agent-runtime';
export function directConversationTurnId(messageId: string | null | undefined) {
const match = /^direct-codex:(.+):(user|assistant|failure)$/.exec(
messageId ?? '',
);
return match?.[1] ?? null;
}
export type DirectTurnPresentation = {
key: string;
turnId: string | null;
messages: ChatMessage[];
notices: ChatMessage[];
items: TurnStreamItem[];
calls: GameCreatorDirectToolCall[];
source: 'stream' | 'messages';
active: boolean;
transientReply: string;
startedAt: number;
endedAt: number;
};
function milliseconds(value: number | undefined) {
return value && Number.isFinite(value) && value > 0
? value < 100_000_000_000
? value * 1000
: value
: 0;
}
/** 完整历史先按身份归属,再决定可见回合;渲染层只消费这一个列表。 */
export function buildDirectTurnPresentations({
messages,
visibleMessages,
items,
calls,
activeTurnId,
transientReply,
hasUnloadedHistory = false,
}: {
messages: readonly ChatMessage[];
visibleMessages: readonly ChatMessage[];
items: readonly TurnStreamItem[];
calls: readonly GameCreatorDirectToolCall[];
activeTurnId?: string | null;
transientReply: string;
hasUnloadedHistory?: boolean;
}): DirectTurnPresentation[] {
const rows = new Map<string, DirectTurnPresentation>();
const itemTurnIds = new Map<string, string>();
for (const item of items) {
const prefix = `text:${item.turnId}:`;
if (item.kind === 'text' && item.id.startsWith(prefix)) {
itemTurnIds.set(item.id.slice(prefix.length), item.turnId);
}
}
const ensure = (turnId: string | null, key: string) => {
const existing = rows.get(key);
if (existing) return existing;
const row: DirectTurnPresentation = {
key,
turnId,
messages: [],
notices: [],
items: [],
calls: [],
source: 'messages',
active: Boolean(turnId && turnId === activeTurnId),
transientReply: '',
startedAt: 0,
endedAt: 0,
};
rows.set(key, row);
return row;
};
// 原始 assistant id 可通过流 item 精确反查。旧用户记录无 turn id 时,
// 只用同一用户记录区间内的这种精确证据关联,不与第 N 个工具回合配对。
const boundaryTurnIds = new Map<number, string>();
let boundary = -1;
messages.forEach((message, index) => {
if (message.role === 'user') boundary = index;
const id =
directConversationTurnId(message.messageId) ??
itemTurnIds.get(message.messageId ?? '');
if (id && boundary >= 0 && !boundaryTurnIds.has(boundary)) {
boundaryTurnIds.set(boundary, id);
}
});
const visible = new Set(visibleMessages);
const visibleKeys = new Set<string>();
const seenMessages = new Set<string>();
boundary = -1;
messages.forEach((message, index) => {
if (message.role === 'user') boundary = index;
const turnId =
directConversationTurnId(message.messageId) ??
itemTurnIds.get(message.messageId ?? '') ??
boundaryTurnIds.get(boundary) ??
null;
const key = turnId
? `turn:${turnId}`
: `history:${boundary < 0 ? index : boundary}`;
const row = ensure(turnId, key);
if (visible.has(message)) visibleKeys.add(key);
if (message.messageId && seenMessages.has(message.messageId)) return;
if (message.messageId) seenMessages.add(message.messageId);
row.messages.push(message);
});
const itemsByKey = new Map<string, TurnStreamItem>();
for (const item of items) {
const key = `${item.turnId}\0${item.id}`;
const previous = itemsByKey.get(key);
if (!previous || item.updatedAt >= previous.updatedAt)
itemsByKey.set(key, item);
}
for (const item of itemsByKey.values()) {
ensure(item.turnId, `turn:${item.turnId}`).items.push(item);
}
const callsByKey = new Map<string, GameCreatorDirectToolCall>();
for (const call of calls) {
const key = `${call.turnId}\0${call.id}`;
const previous = callsByKey.get(key);
if (!previous || call.updatedAt >= previous.updatedAt)
callsByKey.set(key, call);
}
for (const call of callsByKey.values()) {
ensure(call.turnId, `turn:${call.turnId}`).calls.push(call);
}
if (activeTurnId) ensure(activeTurnId, `turn:${activeTurnId}`);
for (const row of rows.values()) {
row.items.sort((a, b) => a.seq - b.seq || a.id.localeCompare(b.id));
row.calls.sort(
(a, b) => a.startedAt - b.startedAt || a.id.localeCompare(b.id),
);
const assistants = row.messages.filter(
(message) => message.role === 'assistant',
);
const rawAssistants = assistants.filter(
(message) =>
message.messageId && !directConversationTurnId(message.messageId),
);
const textIds = new Set(
row.items.filter((item) => item.kind === 'text').map((item) => item.id),
);
// 精确 item id 才能补齐旧快照正文;无法证明流覆盖完整历史时,整轮使用历史,
// 不同时渲染“部分流 + 全文”,也不按长度比例猜测是否重复。
const historyCovered = assistants.every(
(message) =>
Boolean(directConversationTurnId(message.messageId)) ||
Boolean(
message.messageId &&
textIds.has(`text:${row.turnId}:${message.messageId}`),
),
);
row.source =
row.items.length > 0 &&
historyCovered &&
(textIds.size > 0 || assistants.length === 0)
? 'stream'
: 'messages';
if (row.source === 'stream') {
row.notices = assistants.filter(
(message) =>
message.messageId === `direct-codex:${row.turnId}:failure` &&
!textIds.has(`text:${row.turnId}:failure`),
);
const historyById = new Map(
rawAssistants.map((message) => [
`text:${row.turnId}:${message.messageId}`,
message,
]),
);
row.items = row.items.map((item) => {
const history = historyById.get(item.id);
return item.kind === 'text' && history
? { ...item, text: projectSupervisorChatMessageText(history) }
: item;
});
}
if (row.source === 'messages' && rawAssistants.length > 0) {
const lastText = row.items.filter((item) => item.kind === 'text').at(-1);
const lastRaw = rawAssistants.at(-1);
// 最后一个原始 item 与流尾身份一致时,GUI 整轮回执不是另一条回复。
if (
lastText &&
lastRaw &&
lastText.id === `text:${row.turnId}:${lastRaw.messageId}`
) {
row.messages = row.messages.filter(
(message) =>
message.messageId !== `direct-codex:${row.turnId}:assistant`,
);
}
}
if (row.active && row.source === 'messages' && assistants.length === 0) {
row.transientReply = transientReply;
}
const starts = [
...row.messages
.filter((message) => message.role === 'user')
.map((message) => milliseconds(message.updatedAt)),
...row.items.map((item) => milliseconds(item.at)),
...row.calls.map((call) => milliseconds(call.startedAt)),
].filter((at) => at > 0);
row.startedAt = starts.length ? Math.min(...starts) : 0;
row.endedAt = Math.max(
0,
...row.messages.map((message) => milliseconds(message.updatedAt)),
...row.items.map((item) => milliseconds(item.updatedAt)),
...row.calls.map((call) => milliseconds(call.updatedAt)),
);
}
return [...rows.values()].filter(
(row) =>
row.active ||
visibleKeys.has(row.key) ||
(!hasUnloadedHistory &&
row.messages.length === 0 &&
(row.items.length > 0 || row.calls.length > 0)),
);
}
@@ -87,6 +87,19 @@ export function toolCallRowText(call: GameCreatorDirectToolCall) {
}
function toolCallRowSummary(call: GameCreatorDirectToolCall) {
if (call.kind === 'command') {
// 历史摘要可能已被可执行文件路径占满;优先从完整、已脱敏的输入提取正文。
const script = windowsPowerShellCommandBody(
call.detail.command?.trim() || call.summary.trim(),
);
if (script) {
const firstLine = script.trim().split(/\r?\n/, 1)[0] ?? '';
const chars = Array.from(firstLine);
return chars.length > 120
? `${chars.slice(0, 120).join('')}`
: firstLine;
}
}
const summary = call.summary.trim();
if (summary) {
return summary;
@@ -100,6 +113,66 @@ function toolCallRowSummary(call: GameCreatorDirectToolCall) {
return call.title.trim();
}
/** 仅格式化卡片输入,不修改执行参数、历史记录或工具输出。 */
export function toolCallInputText(call: GameCreatorDirectToolCall) {
const input = call.detail.command?.trim() ?? '';
return call.kind === 'command'
? (windowsPowerShellCommandBody(input) ?? input)
: input;
}
function windowsPowerShellCommandBody(command: string): string | null {
const invocation = command.match(
/^(?:&\s+)?("[^"]+"|'[^']+'|[^\s]+)\s+([\s\S]+)$/,
);
if (!invocation) return null;
const executable = (invocation[1] ?? '').replace(/^(['"])(.*)\1$/, '$2');
if (!/(?:^|[\\/])(?:pwsh|powershell)(?:\.exe)?$/i.test(executable)) {
return null;
}
const args = (invocation[2] ?? '').match(
/^(?:(?:-NoLogo|-NoProfile|-NonInteractive|-NoExit|-STA|-MTA)\s+|(?:-ExecutionPolicy|-WindowStyle)\s+\S+\s+)*-(?:Command|c)\s+([\s\S]+)$/i,
);
if (!args?.[1]) return null;
return unwrapDisplayArgument(args[1].trim());
}
/** 解开命令展示字符串的单个 shell 参数,包括 shlex 的单引号拼接;不解析脚本语法。 */
function unwrapDisplayArgument(argument: string) {
if (!/^['"]/.test(argument)) return argument;
let quote = '';
let result = '';
for (let index = 0; index < argument.length; index += 1) {
const char = argument.charAt(index);
if (quote) {
if (char === quote) {
quote = '';
} else if (
quote === '"' &&
char === '\\' &&
/["\\$`\n]/.test(argument[index + 1] ?? '')
) {
index += 1;
if (argument[index] !== '\n') result += argument[index];
} else {
result += char;
}
} else if (char === "'" || char === '"') {
quote = char;
} else if (char === '\\' && index + 1 < argument.length) {
index += 1;
result += argument[index];
} else if (/\s/.test(char)) {
// 多个参数或外层还有后续命令时保留原文,避免错误拼接。
return argument;
} else {
result += char;
}
}
// 历史输入可能在 4000 字符处截断,不猜测丢失的引号。
return quote || !result ? argument : result;
}
/**
* 单条工具的耗时(毫秒)。
* `startedAt` 为 0(缺失)或 `updatedAt < startedAt`(时间倒序)时返回 `null`
@@ -191,7 +264,7 @@ export function turnToolCallEndedAt(
return maxUpdatedAt;
}
/** 本地 `HH:mm`;时间戳缺失(0 / 非法)返回 `null`,不编造时间。 */
/** 本地 `HH:mm:ss`;时间戳缺失(0 / 非法)返回 `null`,不编造时间。 */
export function formatClockTime(timestamp: number | null | undefined) {
if (
timestamp === null ||
@@ -204,11 +277,12 @@ export function formatClockTime(timestamp: number | null | undefined) {
const date = new Date(timestamp);
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${hours}:${minutes}`;
const seconds = String(date.getSeconds()).padStart(2, '0');
return `${hours}:${minutes}:${seconds}`;
}
/**
* 块头时间文案:该回合结束时间(`max(updatedAt)` 的本地 `HH:mm`);
* 块头时间文案:该回合结束时间(`max(updatedAt)` 的本地 `HH:mm:ss`);
* 能拿到同回合用户消息时间(`updatedAt > 0`)时显示「发送 → 结束」,取不到就只显示结束时间。
*/
export function turnToolCallTimeLabel(
+21 -22
View File
@@ -10786,16 +10786,13 @@ button.design-workspace-tree__entry:hover,
margin-left: auto;
}
/* 左侧控件组整体左移 `+` 图标的**字形**与输入区文字的左边缘对齐
控件钮是 28×28 的点击区图标在其中居中实测图标字形比按钮盒左边缘再右偏
(28-15)/2 6.5px所以光对齐按钮盒是不够的那正是光标比下面那排控件更靠左
的成因这里用 -7px 抵消这段居中偏移点击区尺寸不变 */
/* 左侧引用按钮保留输入盒的内边距,点击区域不向外偏移。 */
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer.is-direct-codex
.project-supervisor-composer-controls
.project-supervisor-composer-controls-left {
margin-left: -7px;
margin-left: 0;
}
/* 模型选择钮在控制排里按流式排布但它同时是**弹层锚点**`.conversation-model-menu`
@@ -11064,8 +11061,21 @@ button.design-workspace-tree__entry:hover,
.project-supervisor-composer.is-direct-codex
.project-supervisor-stop-button {
border-radius: 999px;
background: var(--platform-button-ghost-fill);
color: var(--platform-text-strong);
/* 停止是破坏性操作:用红色填充 + 红色图标,和发送/其它控件区分开。 */
background: rgb(197 62 62 / 14%);
color: #c53e3e;
}
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer.is-direct-codex
.project-supervisor-stop-button:hover:not(:disabled),
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer.is-direct-codex
.project-supervisor-stop-button:focus-visible {
background: rgb(197 62 62 / 22%);
color: #a82f2f;
}
.game-workbench-chat
@@ -12024,13 +12034,9 @@ button.design-workspace-tree__entry:hover,
`.resource-reference-input` / `.resource-reference-input-actions` 规则决定
同特异性下只有靠后写的才能盖回去
两处造成"文字和下面那排控件对不齐 / 左右留白不等于上下留白"的原因
1. 输入区是两列网格文本列 + 操作列右侧那枚AI 润色按钮常年占掉 36px
输入区是两列网格文本列 + 操作列右侧那枚AI 润色按钮常年占掉 36px
于是文字右边缘比盒内右边缘少 36px左右留白与上下留白看着就不一致
这里把输入区改成单块并把操作按钮浮到右下角文字可用满整宽
2. `+` `@` 28×28 点击区图标在其中居中lucide 视框本身还有约 3px 内缩
字形比文字左边缘右偏约 5px实测左侧控件组左移 12px 抵消这段偏移
点击区尺寸不变 */
这里把输入区改成单块并把操作按钮浮到右下角文字可用满整宽 */
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer.is-direct-codex
@@ -12053,14 +12059,6 @@ button.design-workspace-tree__entry:hover,
justify-content: flex-end;
}
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer.is-direct-codex
.project-supervisor-composer-controls
.project-supervisor-composer-controls-left {
margin-left: -12px;
}
/* 过程卡任务执行中 / 正在思考中现在渲染在消息列表****紧贴输入盒上方固定在
输入框上面不随消息滚走它不再继承消息列表的左右 16px 内缩所以要自己补齐
才能与消息内容输入盒两侧对齐离开列表后列表的 padding-bottom 也不再作用于它 */
@@ -12157,6 +12155,7 @@ button.design-workspace-tree__entry:hover,
.message-turn-usage {
margin: 0;
padding: 0 2px;
text-align: right;
color: var(--platform-text-soft);
font-size: 12px;
line-height: 1.6;
@@ -12225,4 +12224,4 @@ button.design-workspace-tree__entry:hover,
.project-supervisor-message-list
.message li {
line-height: 1.5 !important;
}
}
@@ -7084,9 +7084,7 @@ export function registerProjectSupervisorSurfaceTests() {
within(supervisorSurface).queryByLabelText('项目总控 Agent 状态'),
).toBeNull();
expect(
within(supervisorSurface).getByText(
'告诉陶泥儿接下来要做什么,或输入 @ 选择资源',
),
within(supervisorSurface).getByText('描述你的想法,或 @ 引用素材'),
).not.toBeNull();
expect(
within(supervisorSurface).getByRole('button', { name: '发送' }),
@@ -9007,7 +9005,7 @@ export function registerProjectSupervisorSurfaceTests() {
).not.toBeNull();
expect(
within(emptyState as HTMLElement).getByText(
'告诉陶泥儿接下来要做什么,或输入 @ 选择资源',
'描述你的想法,或 @ 引用素材',
),
).not.toBeNull();
expect(
@@ -58,7 +58,7 @@ export function registerToolCallGroupTests() {
toolCallRowText(
toolCall({ id: 'a', kind: 'command', summary: 'npm run build' }),
),
).toBe('已运行 npm run build');
).toBe('npm run build');
expect(
toolCallRowText(
toolCall({
@@ -67,17 +67,17 @@ export function registerToolCallGroupTests() {
summary: 'game/src/hero.ts',
}),
),
).toBe('已编辑 game/src/hero.ts');
).toBe('game/src/hero.ts');
expect(
toolCallRowText(
toolCall({ id: 'c', kind: 'mcp_tool', summary: 'canvas.sync' }),
),
).toBe('已调用 canvas.sync');
).toBe('canvas.sync');
expect(
toolCallRowText(
toolCall({ id: 'd', kind: 'web_search', summary: '弹幕游戏玩法' }),
),
).toBe('已搜索 弹幕游戏玩法');
).toBe('弹幕游戏玩法');
// 上下文整理不带 summary。
expect(
toolCallRowText(
@@ -87,12 +87,12 @@ export function registerToolCallGroupTests() {
summary: 'should be ignored',
}),
),
).toBe('整理上下文');
).toBe('整理上下文');
expect(
toolCallRowText(
toolCall({ id: 'f', kind: 'other', summary: '未知动作' }),
),
).toBe('已执行 未知动作');
).toBe('未知动作');
});
it('renders one collapsed block per turn and lists every tool on expand', async () => {
@@ -141,13 +141,13 @@ export function registerToolCallGroupTests() {
'file_change',
]);
expect(
within(rows[0] as HTMLElement).getByText('已运行 npm run build'),
within(rows[0] as HTMLElement).getByText('npm run build'),
).not.toBeNull();
expect(
within(rows[1] as HTMLElement).getByText('已编辑 game/src/hero.ts'),
within(rows[1] as HTMLElement).getByText('game/src/hero.ts'),
).not.toBeNull();
// running 的行在行内标「执行中」。
expect(within(rows[1] as HTMLElement).getByText('执行')).not.toBeNull();
// 已结束回合里的 running 残留快照不能继续显示「执行中」。
expect(within(rows[1] as HTMLElement).getByText('执行')).not.toBeNull();
});
it('expands a row to its own detail and renders nothing for an empty turn', () => {
@@ -158,7 +158,10 @@ export function registerToolCallGroupTests() {
id: 'a',
kind: 'command',
summary: 'npm run build',
detail: { command: 'npm run build', output: 'build ok' },
detail: {
command: "pwsh -Command 'npm run build'",
output: 'build ok',
},
}),
toolCall({
id: 'b',
@@ -181,7 +184,9 @@ export function registerToolCallGroupTests() {
// 行也是按钮:`aria-expanded` + `aria-controls` 指向自己的明细,默认折叠。
const commandHead = within(rows[0] as HTMLElement).getByRole('button');
expect(commandHead.getAttribute('aria-expanded')).toBe('false');
expect(commandHead.getAttribute('aria-label')).toBe('已运行 npm run build');
expect(commandHead.getAttribute('aria-label')).toBe(
'npm run build,已执行',
);
const commandDetail = container.querySelector(
`#${commandHead.getAttribute('aria-controls')}`,
);
@@ -189,14 +194,15 @@ export function registerToolCallGroupTests() {
fireEvent.click(commandHead);
expect(commandHead.getAttribute('aria-expanded')).toBe('true');
expect(commandDetail?.hasAttribute('hidden')).toBe(false);
expect(
commandDetail?.querySelector('.agent-tool-call-row-command')?.textContent,
).toBe('npm run build');
expect(
within(commandDetail as HTMLElement).getByText('build ok'),
).not.toBeNull();
// 文件变更明细:路径 + 变更类型。
const fileHead = within(rows[1] as HTMLElement).getByRole('button');
expect(fileHead.getAttribute('aria-label')).toBe(
'已编辑 game/src/hero.ts,失败',
);
expect(fileHead.getAttribute('aria-label')).toBe('game/src/hero.ts,失败');
fireEvent.click(fileHead);
const fileDetail = container.querySelector(
`#${fileHead.getAttribute('aria-controls')}`,
@@ -261,9 +267,9 @@ export function registerToolCallGroupTests() {
// 块头时间:取得到用户消息时间就是「发送 → 结束」,取不到只显示结束时间,都取不到就不显示。
expect(turnToolCallTimeLabel(calls, 1000)).toMatch(
/^\d{2}:\d{2} → \d{2}:\d{2}$/,
/^\d{2}:\d{2}:01 → \d{2}:\d{2}:09$/,
);
expect(turnToolCallTimeLabel(calls, 0)).toMatch(/^\d{2}:\d{2}$/);
expect(turnToolCallTimeLabel(calls, 0)).toMatch(/^\d{2}:\d{2}:09$/);
expect(
turnToolCallTimeLabel([toolCall({ id: 'e', kind: 'command' })], 0),
).toBe(null);
@@ -309,10 +315,10 @@ export function registerToolCallGroupTests() {
expect(head.getAttribute('aria-label')).toBe(
'已执行 1 个命令、1 个文件变更、1 个联网搜索,用时 17秒',
);
// 时间戳不写死时区:`HH:mm → HH:mm`(发送 → 结束)。
// 时间戳不写死时区:`HH:mm:ss → HH:mm:ss`(发送 → 结束)。
expect(
head.querySelector('.agent-tool-call-group-time')?.textContent,
).toMatch(/^\d{2}:\d{2}\d{2}:\d{2}$/);
).toMatch(/^\d{2}:\d{2}:\d{2} → \d{2}:\d{2}:\d{2}$/);
fireEvent.click(head);
const rows = within(group).queryAllByTestId('agent-tool-call-row');
@@ -323,10 +329,10 @@ export function registerToolCallGroupTests() {
// startedAt === updatedAt:耗时为 0 —— `data-duration-ms` 如实暴露 0,但行上不显示 `0s`。
expect(rows[2]?.getAttribute('data-duration-ms')).toBe('0');
expect(within(rows[2] as HTMLElement).queryByText('0s')).toBeNull();
// 块尾显示该回合结束时间。
// 时间只显示在块头,展开后不重复追加块尾时间。
expect(
within(group).getByTestId('agent-tool-call-group-end-time').textContent,
).toMatch(/^结束于 \d{2}:\d{2}$/);
within(group).queryByTestId('agent-tool-call-group-end-time'),
).toBeNull();
// 时间戳缺失(startedAt 为 0):块头与行都不显示耗时。
const missing = render(
@@ -0,0 +1,175 @@
import { describe, expect, it } from 'vitest';
import type {
ChatMessage,
GameCreatorDirectToolCall,
TurnStreamItem,
} from '../src/app/types';
import { buildDirectTurnPresentations } from '../src/features/project-workspace/directTurnPresentation';
const user = (turn: string): ChatMessage => ({
role: 'user',
text: '只读检查',
messageId: `direct-codex:${turn}:user`,
});
const assistant = (id: string, text = '完整回复'): ChatMessage => ({
role: 'assistant',
text,
messageId: id,
});
const text = (turnId: string, id: string, seq = 1): TurnStreamItem => ({
schemaVersion: 'agc-turn-stream.v1',
kind: 'text',
turnId,
id: `text:${turnId}:${id}`,
text: '前缀',
seq,
at: 1_800_000_000_000,
updatedAt: 1_800_000_000_001,
});
const tool = (turnId: string): GameCreatorDirectToolCall => ({
schemaVersion: 'agc-tool-call.v1',
id: 'call',
turnId,
kind: 'mcp_tool',
title: '调用工具',
summary: '读取',
status: 'completed',
detail: { command: '{"path":"file"}', output: '内容', changes: [] },
startedAt: 1_800_000_000_000,
updatedAt: 1_800_000_000_001,
});
const build = (
messages: ChatMessage[],
items: TurnStreamItem[],
options: Partial<Parameters<typeof buildDirectTurnPresentations>[0]> = {},
) =>
buildDirectTurnPresentations({
messages,
visibleMessages: messages,
items,
calls: [],
transientReply: '',
...options,
});
describe('DirectProject 回合唯一呈现', () => {
it('历史仍有未加载切片时,不把那些回合的工具流追加到当前页末尾', () => {
const rows = build(
[user('one')],
[text('old', 'raw-old'), text('one', 'raw')],
{
hasUnloadedHistory: true,
},
);
expect(rows.map((row) => row.turnId)).toEqual(['one']);
const active = build([], [text('live', 'raw')], {
hasUnloadedHistory: true,
activeTurnId: 'live',
});
expect(active.map((row) => row.turnId)).toEqual(['live']);
});
it('尚未落盘用户的实时回合也只产生一个 owner,不另建 live 与 unmapped 出口', () => {
const rows = build([], [text('one', 'raw')], {
activeTurnId: 'one',
transientReply: '同一份累计回复',
calls: [tool('one')],
});
expect(rows).toHaveLength(1);
expect(rows[0].source).toBe('stream');
expect(rows[0].transientReply).toBe('');
expect(rows[0].calls).toHaveLength(1);
});
it('同一身份用户重复快照不增加回合,不丢用户正文', () => {
const rows = build([user('one'), user('one')], [text('one', 'raw')]);
expect(rows).toHaveLength(1);
expect(rows[0].messages).toHaveLength(1);
expect(rows[0].messages[0].role).toBe('user');
});
it('用原始 item 身份补齐旧前缀,最终合成消息不另占正文出口', () => {
const rows = build(
[user('one'), assistant('raw'), assistant('direct-codex:one:assistant')],
[text('one', 'raw')],
);
expect(rows).toHaveLength(1);
expect(rows[0].source).toBe('stream');
expect(rows[0].items).toHaveLength(1);
expect(rows[0].items[0].text).toBe('完整回复');
});
it('先关联完整历史再分页,首条可见 assistant 不会失去用户归属', () => {
const messages = [
user('old'),
assistant('old-raw'),
user('one'),
assistant('raw'),
];
const rows = build(messages, [text('old', 'old-raw'), text('one', 'raw')], {
visibleMessages: messages.slice(3),
});
expect(rows.map((row) => row.turnId)).toEqual(['one']);
expect(rows[0].messages[0].messageId).toBe('direct-codex:one:user');
});
it('纯文本旧回合不挤占之后有工具的回合身份', () => {
const rows = build(
[user('old'), assistant('plain'), user('one'), assistant('raw')],
[text('one', 'raw')],
{ calls: [tool('one')] },
);
expect(rows.map((row) => row.turnId)).toEqual(['old', 'one']);
expect(rows[0].source).toBe('messages');
expect(rows[0].calls).toEqual([]);
expect(rows[1].source).toBe('stream');
});
it('没有流的原始 assistant 与工具仍归属一个回合,输入输出保留', () => {
const rows = build([user('one'), assistant('raw')], [], {
calls: [tool('one')],
});
expect(rows).toHaveLength(1);
expect(rows[0].source).toBe('messages');
expect(rows[0].calls[0].detail.output).toBe('内容');
});
it('无法证明流覆盖历史时整轮回退,不能混画部分流与全文', () => {
const rows = build(
[user('one'), assistant('raw-a'), assistant('raw-b')],
[text('one', 'raw-b')],
);
expect(rows[0].source).toBe('messages');
expect(
rows[0].messages.filter((message) => message.role === 'assistant'),
).toHaveLength(2);
});
it('不同回合的相同文字不是重复消息,不做文本去重', () => {
const rows = build(
[user('one'), assistant('raw-one'), user('two'), assistant('raw-two')],
[text('one', 'raw-one'), text('two', 'raw-two')],
);
expect(rows).toHaveLength(2);
expect(rows.every((row) => row.source === 'stream')).toBe(true);
});
it('乱序重复快照按 seq 排列且不增加 item', () => {
const first = text('one', 'a');
const last = text('one', 'b', 3);
const marker: TurnStreamItem = {
...text('one', 'unused', 2),
kind: 'tool',
id: 'tool:one:call',
callId: 'call',
};
const rows = build([user('one')], [last, marker, first, first]);
expect(rows[0].items.map((item) => item.seq)).toEqual([1, 2, 3]);
});
it('无流活动回合的累计文本只属于该回合,持久 assistant 到达即接管', () => {
expect(
build([user('one')], [], {
activeTurnId: 'one',
transientReply: '回复',
})[0].transientReply,
).toBe('回复');
expect(
build([user('one'), assistant('direct-codex:one:assistant')], [], {
activeTurnId: 'one',
transientReply: '回复',
})[0].transientReply,
).toBe('');
});
});
@@ -0,0 +1,75 @@
import { describe, expect, it } from 'vitest';
import type { GameCreatorDirectToolCall } from '../src/app/types';
import {
toolCallInputText,
toolCallRowText,
} from '../src/features/project-workspace/toolCallGroupPresentation';
function commandCall(command: string): GameCreatorDirectToolCall {
return {
schemaVersion: 'agc-tool-call.v1',
id: 'command',
turnId: 'turn',
kind: 'command',
title: '执行命令',
summary: command.slice(0, 120),
status: 'completed',
startedAt: 1,
updatedAt: 2,
detail: { command, output: 'pwsh -Command output must stay unchanged' },
};
}
describe('Windows 命令卡片展示', () => {
it('从 WindowsApps 路径后的完整输入提取脚本,并解开 shlex 引号拼接', () => {
const script = `foreach ($f in @('game/package.json','game/vite.config.js','game/index.html','game/game.js')) { Write-Output "===== $f ====="; Get-Content -Raw $f }`;
const argument = "'" + script.replaceAll("'", "'\"'\"'") + "'";
const command = String.raw`"<absolute-path> Files\WindowsApps\Microsoft.PowerShell_7.6.6.0_x64__8wekyb3d8bbwe\pwsh.exe" -Command ${argument}`;
const call = commandCall(command);
expect(toolCallInputText(call)).toBe(script);
expect(toolCallRowText(call)).toBe(`${script.slice(0, 120)}`);
expect(call.detail.command).toBe(command);
expect(call.detail.output).toBe('pwsh -Command output must stay unchanged');
});
it.each([
['pwsh -Command Get-Date', 'Get-Date'],
[
'pwsh.exe -NoLogo -NoProfile -NonInteractive -Command "Get-Date"',
'Get-Date',
],
[
String.raw`"C:\Program Files\PowerShell\7\pwsh.exe" -c 'Get-Date'`,
'Get-Date',
],
["POWERSHELL.EXE -ExecutionPolicy Bypass -Command 'Get-Date'", 'Get-Date'],
[
String.raw`pwsh -Command "Write-Output \"hello\"; Get-Content C:\game\a.txt"`,
String.raw`Write-Output "hello"; Get-Content C:\game\a.txt`,
],
["pwsh -Command 'Get-Date\nGet-Location'", 'Get-Date\nGet-Location'],
["pwsh -Command 'Get-Date…", "'Get-Date…"],
])('隐藏启动器:%s', (command, script) => {
expect(toolCallInputText(commandCall(command))).toBe(script);
});
it.each([
'npm run build',
'pwsh -File game/build.ps1',
'pwsh -EncodedCommand ZgBvAG8A',
'not-pwsh.exe -Command Get-Date',
'echo pwsh -Command Get-Date',
"bash -c 'echo hello'",
])('保留非目标调用:%s', (command) => {
expect(toolCallInputText(commandCall(command))).toBe(command);
});
it('不改写 MCP 工具参数', () => {
const call = {
...commandCall('pwsh -Command Get-Date'),
kind: 'mcp_tool' as const,
};
expect(toolCallInputText(call)).toBe('pwsh -Command Get-Date');
});
});
@@ -0,0 +1,10 @@
# 对话回合唯一投影实施计划
对应:[对话回合唯一投影](./【里程碑】对话回合唯一投影-2026-09-16.md)。
1. 前端抽取纯回合呈现投影,完整历史关联后分页;删除旧的消息锚定/实时/未归属独立渲染分支。
2. Rust 按 item 完成边界冲刷,段切换返回全部快照;收尾等待写任务,修正 upsert 与跨回合裁剪。
3. 核对 MCP 输入输出与同状态更新,保留现有脱敏。
4. 自审正常、失败、历史无流、分页和重复快照路径;只执行定向 tsc、cargo check、check:encoding、check:doc-index 与 git diff --check,不运行测试;按最新授权提交到本地,不推送。
风险:旧流可能只有前缀或部分回合;只依据原始 item 身份补齐,不能靠长度比例推断。实机须重启 Rust 客户端后验收。回滚只撤销本次触及的实现片段,保留工作树原有样式和输入输出修改,不改用户项目数据。
@@ -0,0 +1,26 @@
# 对话回合唯一投影
- Version: 1
- Status: implemented-awaiting-runtime-acceptance
- Date: 2026-09-16
- Parent Spec: ../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md
## 范围与评审
单里程碑修复回合展示和流写入的一致性;不改工具执行、鉴权或原始历史格式。结构评审确认:唯一 turn owner、item 顺序、历史身份与分页、失败保留、工具详情更新均有明确归属;无须新增数据库或迁移历史。没有身份的旧记录不得做位置猜配。
## 验收
1. 一个 turn 只有一个呈现入口,用户消息不丢失。
2. item 增量、完成、持久化和回读保持相同身份与固定顺序。
3. 工具输入输出保留,重复快照不重复渲染。
4. TypeScript、最小 Cargo 检查、编码和 diff 检查通过;用户要求不运行测试,实机新回合/重开/分页验收待确认。
依赖:既有项目历史和 v1 turn-stream / tool-calls DTO。未完成真实 UI 验收前不进入其它里程碑。
## 当前证据
- 定向 TypeScript 类型检查、`cargo check --locked --bin genarrative-ai-game-creator-shell`、编码检查、文档索引检查、`git diff --check` 通过。
- 已补充回合归属、分页、重复快照、无流回退及 writer 完成/切段、持久快照单调性/跨回合裁剪用例;按用户要求未执行测试,不能作为已通过凭证。
- 静态自审确认视图只剩统一回合列表,不再存在 mapped/unmapped/live 三个回合流出口;失败提示使用稳定 failure 身份。
- 真实新回合、历史重开、分页、失败/中断、工具展开输入输出仍待重启原生客户端后验收;仅本地提交,不推送。
@@ -51,6 +51,8 @@ SpacetimeDB crate、SDK、CLI / standalone 与生成 bindings 按 `2.8.3` 对齐
## AGC DirectProject 与 UI workflow
- DirectProject 对话先在完整历史中按回合/原始 item 身份关联,再分页渲染;每个回合只有一个呈现入口。有流按 item `seq` 交替文本和工具,无流采用历史正文;禁止位置猜配或同时展示累计回复与 item 正文。流写入单调归并,收尾等待落盘任务,不按磁盘“最后一段”猜最终回复位置。详见 AGC 实施计划的“DirectProject 回合展示唯一归属”。
- AGC 安装产品名统一为“陶泥儿”,由 Tauri `productName` 控制安装项、快捷方式与 EXE 产品描述;Windows 内置 Codex 安装到顶层 `coding-agent/win-x64/`,打包资源映射与运行时查找路径必须一致。内部可执行文件名与应用 identifier 保持稳定。
- 新 Web 游戏为 `game/` 下的 npm + Vite + Phaser 4.2.1 工程,使用包导入且允许其它依赖;npm 预览与导出只读取 dist,运行素材需纳入构建。单 HTML → Phaser 迁移固定走 DirectProject:文件落盘后先用受控 `project.bootstrap``game` 执行无参数 `npm install`,再用支持相对 cwd 的 `project.verify` 构建并确认 `game/dist/index.html`,已有单 HTML/Godot 不通过 JSON Generator 伪装成 npm 工程。
@@ -4,6 +4,17 @@
`chat_with_game_creator_direct_codex` 必须携带 `projectPath``prompt`、稳定的 `clientTurnId` 和完整 `userItem``creationType``attachments` 按实际输入传递。Rust 通过 `projectPath` 解析项目身份,不接收额外 `projectId`。界面测试必须核对 `userItem` 的消息身份、角色、正文及附件内容,拒绝回合用例仍验证实际返回的错误原因。重开项目的历史恢复测试使用 `read_direct_project_history_slice` 的 canonical raw items 与 `hasMore`,首屏 `limit: 20`。资源图和生成任务的读取继续遵守原有工作台恢复逻辑,不因聊天断言失败延迟、关闭或改变它们。
## 2026-09-16 DirectProject 回合展示唯一归属
- 交付合同:实时消息、历史回读、工具详情与最终回复先归一为按 `clientTurnId` 唯一的回合,再渲染一次。用户消息始终保留;同一回合的正文、工具和耗时不能从消息、实时尾部、未归属尾部等多个出口重复展示。
- 归属来自 `direct-codex:{clientTurnId}:{role}`、文本流中保留的原始 item ID,以及项目历史内明确用户记录之后的 assistant 记录。先在已加载的完整消息集合中关联,再做可见分页;持久历史继续通过 canonical item 切片懒加载,`hasMore` 为真时,未加载回合的工具流不得漂到当前页尾部。禁止将第 N 个有工具回合配给第 N 条用户消息,禁止按文本长度、标点或时间窗猜测归属。缺身份的旧记录保留,不猜造其与其它回合的关联。
- 有回合流时正文与工具位置仅来自 item 边界与 `seq`,工具详情按该回合的 `callId` 关联;没有流时同一个回合容器显示历史消息与工具。整轮累计文本仅在活动回合尚无流和持久 assistant 时作兜底,不另建实时消息出口。
- 同一文本 item 的更新保持位置不变,段切换、工具边界和回合结束冲刷节流内快照;完成事件必须提交完整 item 内容。回合收尾等待已提交的流写入完成,不用磁盘上偶然可见的最后一段推测最终回复身份。JSONL upsert 先合并旧快照,不能先删旧值再“归并”;跨回合保留按回合时间、回合内按 `seq`,不能用各回合从 1 开始的 `seq` 做全局新旧裁剪。
- 工具输入来自 command / MCP arguments,输出来自 aggregatedOutput / output / result,均经过现有脱敏和长度约束。状态相同但详情更新不能被丢弃。
- Thread Manager 的原始事件 `seq` 与展示事件的 `sequence` 分开保存,不能将订阅游标用作工具/文本快照的已消费序号。发送队列保存完整结构化输入,上传附件在 Rust 端经过现有校验后并入 canonical user item。
- 不改变模型、工具执行权限、项目原始历史或业务数据,不进行远程写入。缺失的历史流不得通过删除用户消息隐藏,也不得将已有原始正文截断为流前缀。
- 验收覆盖:新建回合、工具与文本交替、重复快照、最终落盘、失败/中断、历史重开、分页边界、无流历史及工具输入输出。静态检查不等于实机通过;按用户要求不运行测试时,真实事件/UI 验收单独标为待验证。
## 2026-09-15 DirectProject 长回合平台会话保活
DirectProject 的生图、素材处理、构建和试玩可能跨越短生命周期 access token 的有效期。普通 `/api/*` 请求和 Codex app-server 已有 401 刷新路径,但 AGC 工具由 Rust 工具桥直接使用客户端当前会话,工具内部的 401 不会自动触发前端刷新。客户端在 DirectProject 回合处于 busy 状态时每 5 分钟调用现有 `requestPlatformSessionRefresh()`;刷新仍复用单飞请求、generation 校验和 native session 安装,不改变凭据来源,也不把 401 降级为成功。刷新失败保持静默,由原始 AGC 工具错误按现有鉴权失败合同返回,避免后台保活覆盖真实错误。
@@ -27,7 +27,7 @@
- **状态单调**:同一 `id` 的每条快照按 `updatedAt` 合并落盘——`updatedAt` 更旧的快照不得覆盖更新的 `status``updatedAt`。逐条快照落盘与回合末整批落盘两条路径会并发竞争,后到的旧快照不能把已经 `completed` / `failed` 的卡片打回 `running``updatedAt` 相同时终态优先;`startedAt` 取最早的非零值(`item/completed` 不一定带 `startedAtMs`)。
- `title` 是折叠态的一行标题,按 kind 固定:`command``执行命令``file_change``编辑 N 个文件`N = changes 去重后数量)、其余见 kind 枚举。
- `summary` 是折叠态标题后面的短摘要:命令取命令首行(截断 120 字符),`file_change` 取首个变更路径。`summary` 的每个来源(命令、变更路径、`tool`)都必须先脱敏再落盘。
- `detail.command` / `detail.output` 各截断到 4000 字符
- `detail.command` 读取原生命令或 MCP `arguments``detail.output` 读取 `aggregatedOutput` / `output` / `result` / `error`;对象格式化为 JSON,先脱敏再各截断到 4000 字符。展开工具行分别显示“输入”“输出”。MCP 摘要保留工具名,不把 JSON 开头的 `{` 当成摘要。状态相同但详情变化也必须更新;完成快照缺少输入字段时保留开始快照的输入
- **路径形状**`detail.changes[].path` 用**项目相对路径**(如 `game/src/x.ts`,分隔符统一成 `/`);项目外的绝对路径落成 `<absolute-path>` 占位。任何情况下都不得写出项目根目录本身、用户家目录或绝对路径的原始值。
- **必须脱敏**(落盘前统一走 `agent/direct_tool_calls.rs``sanitize_detail_text`,顺序:项目路径归一化 → `redact_absolute_path_tokens``redact_secret_tokens``sanitize_error_context`):
- 前缀型密钥沿用 `redact_secret_tokens``sk-…``tnr_sk_…``ghp_…``AKIA…``eyJ…` 等);
@@ -55,10 +55,10 @@ toolCalls?: DirectTurnToolCall[] | null;
- 历史文件缺失 → 返回空数组,不报错。
- 单行损坏 → 逐行读字节并逐行解码,跳过该行继续,不整体失败;只有损坏字节与下一行黏成一行(例如写入被截断、缺失换行)时,被丢掉的也只是那**一行**,其后的合法记录必须继续读回(与 Codex item 流一样是"尽力而为"的展示数据,不是业务真相)。
### 4. 前端合并与渲染(2026-09 修订:一回合一个折叠块)
### 4. 前端合并与渲染(回合唯一归属,连续工具成块)
- 加载对话时把回读结果`turnId` 归并进消息流:**同一回合的工具调用收成一个折叠块**,块插在该回合 **assistant 消息之前**(Codex 是「工具在上、答复在下」),同一回合内按 `startedAt` 升序
- 实时回合(`directCodexProductRuntime``activeDirectCodexTurnRef` 命中)时,块跟着事件增量更新;回合的 assistant 消息还没落盘时,块落在消息流末尾(下一条 assistant 消息一落盘,块就回到它之前),回合结束后由持久化数据接管(不出现重复块,同一 `id` 每条工具只渲染一行)
- 加载对话时按 `turnId` 归并为唯一回合容器,用户消息保留在该回合前部。有 `turn-stream.jsonl` 时,文本与工具按 item `seq` 交替,连续工具合为一块,遇到文本另起一块;没有流的历史回合才采用“工具块 + 历史正文”
- 实时与回读共用同一投影,正文、工具和耗时不另建实时/未归属渲染出口。先在完整历史按消息身份关联,再分页;禁止按第 N 个工具回合匹配第 N 条用户消息。详情通过当前回合 `callId` 关联;同项目回读与实时增量幂等合并,切项目清空旧状态。完整合同见 [AGC 实施计划](./【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md) 的“DirectProject 回合展示唯一归属”
- 块 DOM 与交互(对齐 Codex):
```html
@@ -67,7 +67,7 @@ toolCalls?: DirectTurnToolCall[] | null;
aria-label="已执行 2 个命令、1 个文件变更,用时 42秒">
<span class="agent-tool-call-group-icon" aria-hidden="true"></span>
<span class="agent-tool-call-group-summary">已执行 2 个命令、1 个文件变更</span>
<span class="agent-tool-call-group-time">14:20 → 14:21</span>
<span class="agent-tool-call-group-time">14:20:05 → 14:21:02</span>
<span class="agent-tool-call-group-duration">用时 42秒</span>
<svg class="agent-tool-call-group-chevron" aria-hidden="true"></svg>
</button>
@@ -94,9 +94,11 @@ toolCalls?: DirectTurnToolCall[] | null;
- 文案规则(按 kind,不允许自由发挥):
- 块头汇总按 kind 计数、顺序固定 `command → file_change → mcp_tool → web_search → context_compaction → other`,标签 `命令`/`文件变更`/`工具调用`/`联网搜索`/`上下文整理`/`其他操作`,形如 `已执行 5 个命令、2 个文件变更`;空集合不渲染块。
- 行文案:`command``已运行 {summary}``file_change``已编辑 {summary}``mcp_tool``已调用 {summary}``web_search``已搜索 {summary}``context_compaction``已整理上下文``other``已执行 {summary}``failed` 行加 `失败` 并用现有 `--platform-*` 错误色
- 行文案:展示工具摘要,不重复添加动词前缀;`context_compaction` 固定为“整理上下文”。状态单独放在行尾(执行中 / 已执行 / 失败),`failed` 使用现有 `--platform-*` 错误色;已结束回合不因残留 `running` 快照显示“执行中”
- 耗时:单条工具 = `startedAt``updatedAt`,块头总用时 = 该回合所有工具的 `min(startedAt)``max(updatedAt)`。单条格式:`<1s``0.4s``<60s``12.3s`(整秒省略小数)、`≥60s``2m 5s`;块头格式:`42秒` / `4分钟` / `5分钟 45秒``startedAt` 为 0 或 `updatedAt < startedAt` 时不显示耗时(不显示 `0s` / 负数),耗时为 0 时同样不显示 `0s`
- 时间:块头显示该回合结束时间(`max(updatedAt)` 的本地 `HH:mm`);同一回合能拿到用户消息时间(`updatedAt > 0`)时显示 `HH:mm → HH:mm`(发送 → 结束),取不到就只显示结束时间,不编造。展开态块尾再写一行 `结束于 HH:mm`
- 时间:块头显示该回合结束时间(`max(updatedAt)` 的本地 `HH:mm:ss`);同一回合能拿到用户消息时间(`updatedAt > 0`)时显示 `HH:mm:ss → HH:mm:ss`(发送 → 结束),取不到就只显示结束时间,不编造。
- 回合结束时间与耗时在正文下方右对齐;Direct 对话输入框提示统一为“描述你的想法,或 @ 引用素材”,引用按钮保留输入盒的 12px 内边距,不使用负边距贴边。
- Windows 命令展示:仅 `command` 卡片识别 `pwsh` / `powershell`(含完整路径、`.exe`、常见启动选项)的 `-Command` / `-c` 外层包装,摘要和展开输入只展示脚本正文,并解开单个 shell 参数的引用拼接。摘要优先读取已脱敏的 `detail.command`,再按首行 120 字符截断,避免历史摘要被可执行文件路径占满。无法识别的启动方式、`-File``-EncodedCommand`、普通命令和 MCP 输入原样展示;执行参数、持久化原文、脱敏和输出均不改变。
- 调试属性:块与行都带 `data-duration-ms`(原始毫秒,无法计算时为空串)与稳定 `data-testid`(块 `agent-tool-call-group`、行 `agent-tool-call-row`)。
- 必须用 `<button aria-expanded>` + `hidden` 控制展开(键盘可达、可读屏),块头与行都是按钮:`aria-label` = 汇总 / 行文案 + 耗时;默认折叠。
- 输入框、消息气泡、消息列表滚动模型**不变**;块只是消息流里的一个块。