Merge pull request '完善 AGC 对话流与工具调用,统一消息层级并新增文档预览' (#375) from feat/chat-codex-ui into master
Project CI / AI game creator shell Rust shard 1/4 (push) Failing after 2m7s
Project CI / AI game creator shell Rust shard 2/4 (push) Failing after 2m4s
Project CI / AI game creator shell Rust shard 3/4 (push) Failing after 2m3s
Project CI / AI game creator shell Rust shard 4/4 (push) Failing after 1m46s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m39s
Project CI / AI game creator shell Rust crates (push) Successful in 1m59s
Project CI / Frontend tests (push) Failing after 3m50s
Project CI / Repository checks (push) Failing after 4m10s
Project CI / Native shell tests (push) Successful in 6m4s
Project CI / Backend tests (push) Successful in 6m44s
Project CI / AI game creator shell web tests (push) Failing after 3m3s

Reviewed-on: #375
This commit was merged in pull request #375.
This commit is contained in:
2026-09-16 04:07:40 +08:00
86 changed files with 12512 additions and 1130 deletions
+1
View File
@@ -57,6 +57,7 @@
"react-colorful": "^5.8.0",
"react-dom": "^19.0.0",
"react-markdown": "^10.1.0",
"rehype-highlight": "^7.0.2",
"remark-gfm": "^4.0.1",
"vite": "^6.2.0",
"zustand": "^5.0.14"
@@ -22,7 +22,9 @@ mod direct_project_turn_history;
mod direct_runtime;
mod direct_thread_manager;
mod direct_tool_bridge;
mod direct_tool_calls;
mod direct_tools_mcp;
mod direct_turn_stream;
mod generation;
mod interaction;
mod prompt;
@@ -36,8 +38,9 @@ mod runtime_tools;
mod skill_pack;
use codex_app_server::*;
pub(crate) use codex_app_server::{
cancel_direct_codex_turn_at,
direct_codex_canonical_project_identity_for_commands as direct_codex_canonical_project_identity,
direct_game_creator_codex_chat_at, direct_game_creator_home_codex_chat,
direct_game_creator_codex_chat_at, direct_game_creator_home_codex_chat, DirectTurnCancelView,
};
use codex_cli::*;
pub(crate) use codex_cli::{
@@ -53,7 +56,9 @@ pub(crate) use direct_project_turn_history::*;
pub(crate) use direct_runtime::*;
pub(crate) use direct_thread_manager::*;
pub(crate) use direct_tool_bridge::*;
pub(crate) use direct_tool_calls::*;
pub(crate) use direct_tools_mcp::*;
pub(crate) use direct_turn_stream::*;
pub(crate) use generation::*;
pub(crate) use interaction::*;
pub(crate) use prompt::*;
@@ -222,6 +222,12 @@ impl CodexTurnStartCancellation {
self.maybe_interrupt();
}
/// app-server 连接是否还活着:句柄只剩 Weak 时说明进程已被回收,此时"终止"必须
/// 明确报错,而不是静默成功让界面以为回合已经停了。
fn app_server_alive(&self) -> bool {
self.inner.strong_count() > 0
}
fn cancel(&self) {
self.cancelled.store(true, Ordering::Release);
self.maybe_interrupt();
@@ -576,8 +582,21 @@ enum CodexTurnEvent {
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) enum DirectCodexTurnObservation {
AccumulatedText(String),
/// 一个 assistant 文本段的当前累计全文。
///
/// `item_id` 是一次 assistant 消息的稳定身份:同一个 id 的后续 delta 属于**同一段**
/// id 变了就是新的一段。回合流的"文本段 + 工具"顺序用它来分段,而不是按 delta 分。
AgentMessageSegment {
item_id: String,
accumulated_text: String,
completed: bool,
},
IntermediateText(String),
/// 模型的思考过程(reasoning item 的明文摘要):流式阶段整段替换下发。
Reasoning(String),
Activity(&'static str),
/// 一条结构化工具调用(`item/started` 与 `item/completed` 各采一次,按 id 幂等)。
ToolCall(crate::DirectToolCall),
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -862,6 +881,37 @@ fn direct_codex_mcp_tool_intermediate_text(item: &serde_json::Value) -> String {
/// (with the concrete command/tool/path) while tools run; it does not push
/// plan/reasoning text deltas. Showing what the agent is actually doing is
/// the only reliable way to make the execution phase feel alive.
/// 从 reasoning item 里抽明文思考文本:优先 `summary[].text`,其次 `content[].text`。
///
/// Codex 的 reasoning item 形如
/// `{ "type": "reasoning", "summary": [...], "content": [{ "text": "..." }], "encrypted_content": ... }`
/// 没有 `role` 字段;明文(至少 content/summary 之一)存在时我们才展示,拿不到就返回 None。
fn direct_codex_item_reasoning_text(item: &serde_json::Value) -> Option<String> {
if item.get("type").and_then(serde_json::Value::as_str) != Some("reasoning") {
return None;
}
let collect = |key: &str| -> Option<String> {
let parts = item
.get(key)?
.as_array()?
.iter()
.filter_map(|entry| {
entry
.get("text")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|text| !text.is_empty())
})
.collect::<Vec<_>>();
if parts.is_empty() {
None
} else {
Some(parts.join("\n\n"))
}
};
collect("summary").or_else(|| collect("content"))
}
fn direct_codex_item_intermediate_text(item: &serde_json::Value) -> Option<String> {
const MAX_ITEM_TEXT_CHARS: usize = 240;
let item_type = item
@@ -2734,6 +2784,12 @@ impl CodexAppServerConnection {
let _turn_guard = self.inner.turn_gate.lock().await;
let mut request = request;
let history_root = direct_history_root.unwrap_or(&self.inner.workspace_path);
// 工具调用卡片的 turnId 用 AGC 客户端回合 id(与实时事件、落盘条目同一口径),
// 不用 Codex app-server 自己的 turnId——前端要按它把卡片挂回对应的那一轮。
let direct_tool_call_turn_id: Option<String> = direct_client_turn_id
.map(str::trim)
.filter(|turn_id| !turn_id.is_empty())
.map(str::to_string);
if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
let current_prompt = direct_codex_current_user_prompt(&request).trim();
if current_prompt.is_empty() {
@@ -2821,6 +2877,17 @@ impl CodexAppServerConnection {
}
let turn_start_cancellation =
Arc::new(CodexTurnStartCancellation::new(&self.inner, &thread_id));
// Direct 回合登记为"可终止":终止命令只作用在这一轮上,回合结束时自动注销。
let _active_turn_guard = direct_tool_call_turn_id
.as_deref()
.filter(|_| self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject)
.map(|turn_id| {
register_active_direct_codex_turn(
direct_codex_active_turn_key(history_root),
turn_id,
Arc::clone(&turn_start_cancellation),
)
});
let mut turn_start_guard = CodexTurnStartGuard {
cancellation: Arc::clone(&turn_start_cancellation),
armed: true,
@@ -2938,6 +3005,18 @@ impl CodexAppServerConnection {
observer(DirectCodexTurnObservation::AccumulatedText(
streamed_text.clone(),
));
// 同一个 assistant item 的当前累计全文:回合流按 item 分段,
// 段内只追加、段间才换行,不能拿"整轮累计"当一段。
let segment_text = direct_project_history
.accumulated_text_for(&item_id)
.unwrap_or_else(|| delta.clone());
if !segment_text.trim().is_empty() {
observer(DirectCodexTurnObservation::AgentMessageSegment {
item_id: item_id.clone(),
accumulated_text: segment_text,
completed: false,
});
}
}
if let Some(callback) = on_agent_message_delta.as_deref_mut() {
callback(&platform_llm::LlmStreamDelta {
@@ -3027,6 +3106,10 @@ impl CodexAppServerConnection {
// 让执行期间聊天窗口显示“正在做什么”,而不是只
// 有活动状态来回跳动。completed 事件不再重复。
if !completed {
if let Some(reasoning) = direct_codex_item_reasoning_text(item)
{
observer(DirectCodexTurnObservation::Reasoning(reasoning));
}
if let Some(text) = direct_codex_item_intermediate_text(item) {
observer(DirectCodexTurnObservation::IntermediateText(
text,
@@ -3042,6 +3125,24 @@ impl CodexAppServerConnection {
completed,
&params,
);
// 工具调用卡片:item/started 与 item/completed 各采一次,
// 由下游按 id 幂等 upsert 成同一条。采集失败(拿不到 id /
// 非工具类 item)就静默跳过,不影响这一轮的其它投影。
if let Some(turn_id) = direct_tool_call_turn_id.as_deref() {
if let Some(tool_call) = direct_tool_call_from_item(
history_root,
item,
turn_id,
completed,
direct_tool_call_now_ms(),
) {
if let Some(observer) = direct_observer.as_deref_mut() {
observer(DirectCodexTurnObservation::ToolCall(
tool_call,
));
}
}
}
if completed {
if let Some(audit) = audit.as_mut() {
audit.observe_item(&params);
@@ -3049,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)
@@ -3086,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")
@@ -3195,6 +3332,223 @@ impl Drop for CodexTurnStartGuard {
}
}
/// Direct 回合中断表:与具体取消句柄解耦的最小实现,"选哪一轮 / 注销哪一轮"可单测。
struct DirectCodexActiveTurnTable<T> {
entries: HashMap<std::path::PathBuf, (String, T)>,
}
impl<T> DirectCodexActiveTurnTable<T> {
fn new() -> Self {
Self {
entries: HashMap::new(),
}
}
fn register(&mut self, key: std::path::PathBuf, client_turn_id: &str, value: T) {
self.entries
.insert(key, (client_turn_id.to_string(), value));
}
/// 只有当前登记项仍是本回合的句柄时才注销,避免旧回合的收尾清掉后来注册的回合。
fn unregister(&mut self, key: &Path, is_same: impl Fn(&T) -> bool) {
if self
.entries
.get(key)
.is_some_and(|(_, value)| is_same(value))
{
self.entries.remove(key);
}
}
/// 选中要终止的回合:没有活动回合、或前端给的 clientTurnId 与活动回合不一致时都返回
/// 可读原因,绝不误伤另一个回合。
fn select(&self, key: &Path, client_turn_id: Option<&str>) -> Result<&(String, T), String> {
let active = self
.entries
.get(key)
.ok_or_else(|| "当前项目没有正在运行的陶泥儿回合,无法终止".to_string())?;
if let Some(expected) = client_turn_id
.map(str::trim)
.filter(|value| !value.is_empty())
{
if active.0 != expected {
return Err(DIRECT_CODEX_ANOTHER_TURN_RUNNING_MESSAGE.to_string());
}
}
Ok(active)
}
/// 当前登记在这一轮上的 clientTurnId;没有任何登记时返回 `None`。
fn registered_client_turn_id(&self, key: &Path) -> Option<&str> {
self.entries
.get(key)
.map(|(client_turn_id, _)| client_turn_id.as_str())
}
}
/// "正在跑的是另一轮"的统一文案:`select` 与"终止"兜底路径共用,保证两处拒绝语义一致。
const DIRECT_CODEX_ANOTHER_TURN_RUNNING_MESSAGE: &str = "正在运行的是另一个陶泥儿回合,已拒绝终止";
/// 正在运行的 Direct 回合中断句柄,按项目根(canonical,去掉 Windows `\\?\` 前缀)索引。
///
/// `CodexTurnStartCancellation` 本身已经能在 turn/start 响应到达**前后**发出
/// `turn/interrupt`;这里只是把它留一个 Tauri 命令取得到的引用,回合结束后由
/// [`DirectCodexActiveTurnGuard`] 移除。只做新增:不改既有事件、命令语义。
static GAME_CREATOR_DIRECT_CODEX_ACTIVE_TURNS: OnceLock<
std::sync::Mutex<DirectCodexActiveTurnTable<Arc<CodexTurnStartCancellation>>>,
> = OnceLock::new();
fn direct_codex_active_turns(
) -> &'static std::sync::Mutex<DirectCodexActiveTurnTable<Arc<CodexTurnStartCancellation>>> {
GAME_CREATOR_DIRECT_CODEX_ACTIVE_TURNS
.get_or_init(|| std::sync::Mutex::new(DirectCodexActiveTurnTable::new()))
}
/// 注册键:与 Direct 回合用的 `codex_root` 同一形态(canonical 且去掉 `\\?\` 前缀),
/// 这样前端传进来的项目路径与注册时的路径一定落到同一个键上。
fn direct_codex_active_turn_key(root: &Path) -> std::path::PathBuf {
let canonical = std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
match canonical
.to_str()
.and_then(|value| value.strip_prefix("\\\\?\\"))
{
Some(stripped) => std::path::PathBuf::from(stripped),
None => canonical,
}
}
struct DirectCodexActiveTurnGuard {
key: std::path::PathBuf,
cancellation: Arc<CodexTurnStartCancellation>,
}
impl Drop for DirectCodexActiveTurnGuard {
fn drop(&mut self) {
let Some(active_turns) = GAME_CREATOR_DIRECT_CODEX_ACTIVE_TURNS.get() else {
return;
};
let Ok(mut entries) = active_turns.lock() else {
return;
};
let cancellation = Arc::clone(&self.cancellation);
entries.unregister(&self.key, |current| Arc::ptr_eq(current, &cancellation));
}
}
/// 把一个 Direct 回合登记为"可终止",返回的 guard 在回合结束时注销它。
fn register_active_direct_codex_turn(
key: std::path::PathBuf,
client_turn_id: &str,
cancellation: Arc<CodexTurnStartCancellation>,
) -> DirectCodexActiveTurnGuard {
if let Ok(mut entries) = direct_codex_active_turns().lock() {
entries.register(key.clone(), client_turn_id, Arc::clone(&cancellation));
}
DirectCodexActiveTurnGuard { key, cancellation }
}
/// 已向正在跑的回合发出中断:界面等这一轮自己的收尾复位。
pub(crate) const DIRECT_TURN_CANCEL_OUTCOME_INTERRUPTED: &str = "interrupted";
/// 这一轮已经没有人替它收尾,本地守卫已被兜底释放:界面必须自己复位。
pub(crate) const DIRECT_TURN_CANCEL_OUTCOME_RELEASED: &str = "released";
/// `cancel_direct_codex_turn` 的返回值:界面据此决定是自己复位,还是等回合自己收尾。
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct DirectTurnCancelView {
/// [`DIRECT_TURN_CANCEL_OUTCOME_INTERRUPTED`] 或
/// [`DIRECT_TURN_CANCEL_OUTCOME_RELEASED`]。
pub(crate) outcome: String,
/// 给用户看的可读结果。
pub(crate) message: String,
/// 被终止 / 被释放的 clientTurnId。
pub(crate) client_turn_id: String,
}
/// "终止"这一步要作用在哪:发中断,还是走残留守卫兜底释放。
enum DirectCodexTurnCancelTarget {
/// app-server 侧还有活句柄:正常发 `turn/interrupt`。
Interrupt(Arc<CodexTurnStartCancellation>),
/// app-server 侧已经拿不到可中断的活句柄;带上是哪种情况。
Stale(DirectTaonierStaleGuardReason),
}
/// 终止当前项目正在运行的 Direct 回合。
///
/// 正常路径:只向正在跑的 Codex app-server 回合发 `turn/interrupt`app-server 随后回
/// `turn/completed status=interrupted`,正在 await 的那个回合命令会带着可读原因返回),
/// 不动任何既有事件或命令语义。
///
/// 兜底路径:app-server 侧已经拿不到可中断的活句柄时,说明这一轮不会再有人替它收尾。
/// 只发中断会让本地守卫(`DirectTaonierActiveInvocationGuard`)永远留在进程内,用户此后
/// 每条消息都会被"已有另一条回合正在运行"拒绝——这正是"重进会话被堵死"的死锁形态。
/// 这时显式释放这条守卫并把可读原因返回给界面。释放条件见
/// [`release_stale_direct_taonier_active_invocation`] 的注释;"正在跑的是另一轮"仍然
/// 保持原拒绝语义,什么都不释放。
pub(crate) fn cancel_direct_codex_turn_at(
root: &Path,
client_turn_id: Option<&str>,
) -> Result<DirectTurnCancelView, String> {
let key = direct_codex_active_turn_key(root);
let expected = client_turn_id
.map(str::trim)
.filter(|value| !value.is_empty());
{
let entries = direct_codex_active_turns()
.lock()
.map_err(|_| "Direct 回合中断表已损坏,无法终止".to_string())?;
if let (Some(registered), Some(expected)) =
(entries.registered_client_turn_id(&key), expected)
{
if registered != expected {
return Err(DIRECT_CODEX_ANOTHER_TURN_RUNNING_MESSAGE.to_string());
}
}
}
let target = {
let entries = direct_codex_active_turns()
.lock()
.map_err(|_| "Direct 回合中断表已损坏,无法终止".to_string())?;
match entries.select(&key, client_turn_id) {
Ok((_, cancellation)) if cancellation.app_server_alive() => {
DirectCodexTurnCancelTarget::Interrupt(Arc::clone(cancellation))
}
Ok(_) => {
DirectCodexTurnCancelTarget::Stale(DirectTaonierStaleGuardReason::ExecutorExited)
}
Err(_) => DirectCodexTurnCancelTarget::Stale(
DirectTaonierStaleGuardReason::NeverReachedExecutor,
),
}
};
match target {
DirectCodexTurnCancelTarget::Interrupt(cancellation) => {
cancellation.cancel();
Ok(DirectTurnCancelView {
outcome: DIRECT_TURN_CANCEL_OUTCOME_INTERRUPTED.to_string(),
message: "已向正在运行的回合发出终止".to_string(),
client_turn_id: client_turn_id
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or_default()
.to_string(),
})
}
DirectCodexTurnCancelTarget::Stale(reason) => {
let released =
release_stale_direct_taonier_active_invocation(root, client_turn_id, reason)?;
Ok(DirectTurnCancelView {
outcome: DIRECT_TURN_CANCEL_OUTCOME_RELEASED.to_string(),
message: format!(
"{},已释放这一轮的占用,可以直接重新发送消息",
reason.message()
),
client_turn_id: released,
})
}
}
}
struct CodexThreadLease {
connection: CodexAppServerConnection,
key: CodexNodeThreadKey,
@@ -4100,6 +4454,52 @@ pub(crate) fn build_direct_codex_history_prompt(
mod tests {
use super::*;
/// 终止只作用在"当前项目正在跑的那一轮"上:没有活动回合 / clientTurnId 不匹配都要
/// 返回可读原因,不能误伤别人;注销也只注销本回合自己的句柄。
#[test]
fn direct_codex_active_turn_table_selects_only_the_running_turn() {
let mut table: DirectCodexActiveTurnTable<u8> = DirectCodexActiveTurnTable::new();
let key = std::path::PathBuf::from("C:/projects/direct-turn-demo");
assert_eq!(
table.select(&key, None).expect_err("no active turn"),
"当前项目没有正在运行的陶泥儿回合,无法终止"
);
table.register(key.clone(), "turn-a", 1);
assert_eq!(table.select(&key, None).expect("active turn").0, "turn-a");
assert_eq!(table.select(&key, Some("turn-a")).expect("same turn").1, 1);
assert_eq!(
table
.select(&key, Some("turn-b"))
.expect_err("another running turn"),
"正在运行的是另一个陶泥儿回合,已拒绝终止"
);
// 句柄已被后来的回合替换:旧回合收尾不得注销新回合。
table.register(key.clone(), "turn-b", 2);
table.unregister(&key, |value| *value == 1);
assert_eq!(table.select(&key, None).expect("newer turn").0, "turn-b");
table.unregister(&key, |value| *value == 2);
assert!(table.select(&key, None).is_err());
}
/// 注册键:前端传的项目路径与回合注册时的路径必须归一化成同一个键(Windows 上
/// `canonicalize` 会带 `\\?\` 前缀,去掉后两边才相等)。
#[test]
fn direct_codex_active_turn_key_normalizes_windows_prefix() {
let root = tempfile::tempdir().expect("temp dir");
let canonical = std::fs::canonicalize(root.path()).expect("canonical root");
let expected = canonical
.to_str()
.and_then(|value| value.strip_prefix("\\\\?\\"))
.map(std::path::PathBuf::from)
.unwrap_or(canonical);
let key = direct_codex_active_turn_key(root.path());
assert_eq!(key, expected);
// 归一化后的键不再带 Windows 扩展长度前缀:前端传进来的普通路径才能命中同一个键。
assert!(!key.to_string_lossy().starts_with("\\\\?\\"));
}
#[test]
fn direct_thread_item_projection_drops_full_app_server_payload() {
let item = serde_json::json!({
@@ -5,6 +5,7 @@ use crate::project::{
};
use crate::{LocalConversationMessageRecord, LocalConversationResult};
use serde_json::Value;
use std::collections::BTreeMap;
use std::fs::{self, File};
use std::io::{BufRead, BufReader, Read, Seek, SeekFrom};
use std::path::{Path, PathBuf};
@@ -91,6 +92,10 @@ fn record(item: &Value) -> Result<String, String> {
serde_json::to_string(&serde_json::json!({
"type": DIRECT_PROJECT_HISTORY_RECORD_TYPE,
"payload": item,
"recordedAt": std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_millis() as u64)
.unwrap_or(0),
}))
.map_err(|error| format!("序列化 DirectProject 历史失败:{error}"))
}
@@ -470,6 +475,13 @@ fn direct_project_message_item(role: &str, content: &str, message_id: Option<&st
}
pub(crate) fn read_direct_project_history_items_at(root: &Path) -> Result<Vec<Value>, String> {
Ok(read_direct_project_history_entries_at(root)?
.into_iter()
.map(|(item, _)| item)
.collect())
}
fn read_direct_project_history_entries_at(root: &Path) -> Result<Vec<(Value, u64)>, String> {
let path = history_path(root);
if !prepare_game_creator_private_path_for_read(&path, false, "DirectProject 历史")? {
return Ok(Vec::new());
@@ -507,7 +519,13 @@ pub(crate) fn read_direct_project_history_items_at(root: &Path) -> Result<Vec<Va
if is_direct_project_internal_context_item(&item) {
continue;
}
items.push(item);
items.push((
item,
parsed
.get("recordedAt")
.and_then(Value::as_u64)
.unwrap_or(0),
));
}
Ok(items)
}
@@ -516,18 +534,30 @@ pub(crate) fn read_direct_project_history_items_slice_at(
root: &Path,
before_item_id: Option<&str>,
limit: usize,
) -> Result<(Vec<Value>, bool), String> {
let items = read_direct_project_history_items_at(root)?;
) -> Result<(Vec<Value>, bool, BTreeMap<String, u64>), String> {
let items = read_direct_project_history_entries_at(root)?;
let end = match before_item_id {
Some(item_id) => items
.iter()
.position(|item| item.get("id").and_then(Value::as_str) == Some(item_id))
.position(|(item, _)| item.get("id").and_then(Value::as_str) == Some(item_id))
.ok_or_else(|| format!("DirectProject 历史中不存在 item{item_id}"))?,
None => items.len(),
};
let bounded_limit = limit.clamp(1, 200);
let start = end.saturating_sub(bounded_limit);
Ok((items[start..end].to_vec(), start > 0))
let slice = &items[start..end];
let timestamps = slice
.iter()
.filter_map(|(item, at)| {
let id = item.get("id").and_then(Value::as_str)?;
(*at > 0).then(|| (id.to_string(), *at))
})
.collect();
Ok((
slice.iter().map(|(item, _)| item.clone()).collect(),
start > 0,
timestamps,
))
}
pub(crate) fn read_direct_project_last_item_id_at(root: &Path) -> Result<Option<String>, String> {
@@ -546,10 +576,10 @@ pub(crate) fn read_direct_project_chat_history_at(
root: &Path,
) -> Result<LocalConversationResult, String> {
let path = history_path(root);
let items = read_direct_project_history_items_at(root)?;
let items = read_direct_project_history_entries_at(root)?;
let messages = items
.into_iter()
.filter_map(|item| {
.filter_map(|(item, recorded_at)| {
let role = item.get("role").and_then(Value::as_str)?;
if !matches!(role, "user" | "assistant") {
return None;
@@ -571,7 +601,7 @@ pub(crate) fn read_direct_project_chat_history_at(
content,
agent_id: None,
message_id: item.get("id").and_then(Value::as_str).map(str::to_string),
updated_at: 0,
updated_at: recorded_at,
})
})
.collect();
@@ -610,6 +640,40 @@ mod tests {
const RESPONSE_ITEM_ROW: &str = r#"{"type":"response_item","payload":{"type":"message","role":"user","id":"codex-item-2","content":[{"type":"input_text","text":"再加一个按钮"}]}}"#;
const RESPONSE_ASSISTANT_ROW: &str = r#"{"type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"已完成"}]}}"#;
#[test]
fn history_timestamps_survive_reload_and_idempotent_append_without_changing_raw_items() {
let root = init_history_project("history-time");
let item = json!({
"type": "message", "role": "user", "id": "sent-message",
"content": [{"type": "input_text", "text": "修改游戏"}],
});
append_direct_project_user_message_at(root.path(), &item).unwrap();
let (items, _, timestamps) =
super::read_direct_project_history_items_slice_at(root.path(), None, 20).unwrap();
assert_eq!(items, vec![item.clone()]);
assert!(timestamps["sent-message"] > 0);
append_direct_project_user_message_at(root.path(), &item).unwrap();
let (_, _, reloaded) =
super::read_direct_project_history_items_slice_at(root.path(), None, 20).unwrap();
assert_eq!(timestamps, reloaded);
}
#[test]
fn old_history_without_envelope_time_stays_unknown() {
let root = init_history_project("history-unknown-time");
write_history_lines(root.path(), &[RESPONSE_ITEM_ROW]);
let (_, _, timestamps) =
super::read_direct_project_history_items_slice_at(root.path(), None, 20).unwrap();
assert!(timestamps.is_empty());
assert_eq!(
read_direct_project_chat_history_at(root.path())
.unwrap()
.messages[0]
.updated_at,
0
);
}
/// 判据:争用类失败会被"有界退避重试"真的吃掉,最终把条目落一行。
///
/// 注入标记是"让接下来 N 次单次尝试返回争用失败";退避表只补一次重试,所以注入 1 次
@@ -22,6 +22,14 @@ impl DirectProjectHistoryAccumulator {
}
}
/// 某个 assistant item 目前累计到的全文。
///
/// 回合流按 item 分段:同一个 item 的后续 delta 是同一段的增长,item 变了才是新的一段。
/// 没有这条 item(非 DirectProject 工作区、或已经 complete)时返回 `None`。
pub(crate) fn accumulated_text_for(&self, item_id: &str) -> Option<String> {
self.text_by_item_id.get(item_id).cloned()
}
fn take_partial_items(&mut self) -> impl Iterator<Item = Value> + '_ {
std::mem::take(&mut self.text_by_item_id)
.into_iter()
File diff suppressed because it is too large Load Diff
@@ -31,7 +31,7 @@ pub(crate) fn normalize_direct_client_turn_id(
pub(crate) async fn chat_with_game_creator_direct_codex(
project_path: String,
prompt: String,
user_item: DirectCodexUserItem,
mut user_item: DirectCodexUserItem,
creation_type: Option<String>,
client_turn_id: Option<String>,
attachments: Option<Vec<DirectCodexTurnAttachment>>,
@@ -50,6 +50,17 @@ pub(crate) async fn chat_with_game_creator_direct_codex(
attachments.as_deref().unwrap_or_default(),
);
let attachments = attachments.unwrap_or_default();
if !attachments.is_empty() {
let attachment_context =
render_direct_codex_user_prompt("", &attachments).map_err(|error| {
audit.finish(false);
error
})?;
let DirectCodexUserItem::Message(message) = &mut user_item;
message.content.push(DirectCodexUserContentPart::InputText {
text: attachment_context,
});
}
validate_direct_codex_user_item(root, &user_item).map_err(|error| {
audit.finish(false);
error
@@ -81,6 +92,6 @@ pub(crate) async fn chat_with_game_creator_direct_codex(
}
};
audit.finish(true);
turn_emitter.emit("completed", Some("none"), Some(reply.clone()));
turn_emitter.emit("completed", Some("none"), Some(reply.clone()), None);
Ok(reply)
}
@@ -58,6 +58,7 @@ pub(crate) struct DirectThreadConsumeResult {
pub(crate) struct DirectThreadHistorySlice {
pub(crate) items: Vec<Value>,
pub(crate) has_more: bool,
pub(crate) item_timestamps: std::collections::BTreeMap<String, u64>,
}
#[derive(Clone, Debug)]
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,439 @@
//! GameAgent 对话「回合流」的采集、持久化与回读。
//!
//! 顺序真相放在一处:`<projectRoot>/.agent/conversations/turn-stream.jsonl` 按**出现顺序**
//! 记录一个回合里的文本段与工具调用。工具条目只记位置标记(`callId`),工具本身的正文
//! 仍然来自 `tool-calls.jsonl`(同一 id 幂等合并只有一处实现)。
//!
//! 位置稳定:每条条目的 `seq` 在**首次出现**时由观察方分配并落盘,后续更新(同一 id 的
//! 文本追加 / 工具状态变化)只改内容不改 `seq`。因此并发落盘的先后顺序不会让"新工具插到
//! 旧文本前面"——渲染顺序只由 `seq` 决定。
//!
//! `project.jsonl` 保留原始消息;本流补充文本与工具交替的 item 顺序,不能重复展示两份正文。
use crate::agent::sanitize_detail_text;
use crate::config::{prepare_game_creator_private_path_for_read, write_game_creator_private_file};
use crate::project::{enforce_project_permission_policy, project_append_lock_for};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::BTreeMap;
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
/// 行信封类型,与既有历史文件同构(`{"type": …, "payload": {…}}`)。
pub(crate) const DIRECT_TURN_STREAM_RECORD_TYPE: &str = "turn_stream_item";
/// 条目 schema 版本。
pub(crate) const DIRECT_TURN_STREAM_SCHEMA_VERSION: &str = "agc-turn-stream.v1";
/// 回读上限:只保留最后这么多条(按 `seq` 取最新)。
pub(crate) const DIRECT_TURN_STREAM_LIMIT: usize = 400;
/// 单条文本段的字符上限(与工具明细同口径的截断,避免单段失控)。
const DIRECT_TURN_STREAM_TEXT_MAX_CHARS: usize = 8000;
/// 没有流式分段时,最终回复那一段的固定 item id。
const DIRECT_TURN_STREAM_FINAL_ITEM_ID: &str = "final";
/// 回合失败说明那一段的固定 item id:失败说明也是这一回合的内容,排在流末尾。
pub(crate) const DIRECT_TURN_STREAM_FAILURE_ITEM_ID: &str = "failure";
/// 文本段。
pub(crate) const DIRECT_TURN_STREAM_KIND_TEXT: &str = "text";
/// 工具调用的位置标记。
pub(crate) const DIRECT_TURN_STREAM_KIND_TOOL: &str = "tool";
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct DirectTurnStreamItem {
pub(crate) schema_version: String,
/// 幂等身份:文本段 `text:<turnId>:<itemId>`、工具 `tool:<turnId>:<callId>`。
pub(crate) id: String,
pub(crate) turn_id: String,
/// `text` | `tool`
pub(crate) kind: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) text: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) call_id: Option<String>,
/// 首次出现的写入序号:**顺序真相**,同刻按它排序。
pub(crate) seq: u64,
/// 条目首次出现的本机毫秒时刻。
pub(crate) at: u64,
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())
}
}
fn turn_stream_path(root: &Path) -> PathBuf {
root.join(".agent/conversations/turn-stream.jsonl")
}
/// 文本段条目的幂等 id:同一个 Codex assistant item 只占一行。
pub(crate) fn direct_turn_stream_text_item_id(turn_id: &str, item_id: &str) -> String {
format!("text:{}:{}", turn_id.trim(), item_id.trim())
}
/// 工具条目(位置标记)的幂等 id:同一个 callId 只占一行。
pub(crate) fn direct_turn_stream_tool_item_id(turn_id: &str, call_id: &str) -> String {
format!("tool:{}:{}", turn_id.trim(), call_id.trim())
}
/// 构造一条文本段条目:脱敏 + 截断与 `tool-calls.jsonl` 同口径。
pub(crate) fn direct_turn_stream_text_item(
root: &Path,
turn_id: &str,
item_id: &str,
text: &str,
seq: u64,
at: u64,
updated_at: u64,
) -> DirectTurnStreamItem {
DirectTurnStreamItem {
schema_version: DIRECT_TURN_STREAM_SCHEMA_VERSION.to_string(),
id: direct_turn_stream_text_item_id(turn_id, item_id),
turn_id: turn_id.trim().to_string(),
kind: DIRECT_TURN_STREAM_KIND_TEXT.to_string(),
text: Some(sanitize_stream_text(root, text)),
call_id: None,
seq,
at,
updated_at,
}
}
/// 构造一条工具条目:只记位置,正文仍来自 `DirectToolCall`。
pub(crate) fn direct_turn_stream_tool_item(
turn_id: &str,
call: &crate::DirectToolCall,
seq: u64,
at: u64,
) -> DirectTurnStreamItem {
DirectTurnStreamItem {
schema_version: DIRECT_TURN_STREAM_SCHEMA_VERSION.to_string(),
id: direct_turn_stream_tool_item_id(turn_id, &call.id),
turn_id: turn_id.trim().to_string(),
kind: DIRECT_TURN_STREAM_KIND_TOOL.to_string(),
text: None,
call_id: Some(call.id.trim().to_string()),
seq,
at,
updated_at: call.updated_at,
}
}
/// 文本脱敏 + 截断:与 `tool-calls.jsonl` 同一套 `sanitize_detail_text`。
pub(crate) fn sanitize_stream_text(root: &Path, text: &str) -> String {
let sanitized = sanitize_detail_text(root, text);
if sanitized.chars().count() <= DIRECT_TURN_STREAM_TEXT_MAX_CHARS {
return sanitized;
}
let mut truncated = sanitized
.chars()
.take(DIRECT_TURN_STREAM_TEXT_MAX_CHARS)
.collect::<String>();
truncated.push('…');
truncated
}
fn record_line(item: &DirectTurnStreamItem) -> Result<String, String> {
serde_json::to_string(&serde_json::json!({
"type": DIRECT_TURN_STREAM_RECORD_TYPE,
"payload": item,
}))
.map_err(|error| format!("序列化回合流条目失败:{error}"))
}
/// 解析一行信封;坏行 / 非本文件条目都返回 `None`(尽力而为的展示数据,不整体失败)。
fn stream_item_from_line(line: &str) -> Option<DirectTurnStreamItem> {
let trimmed = line.trim();
if trimmed.is_empty() {
return None;
}
let parsed: Value = serde_json::from_str(trimmed).ok()?;
if parsed.get("type").and_then(Value::as_str) != Some(DIRECT_TURN_STREAM_RECORD_TYPE) {
return None;
}
let payload = parsed.get("payload")?;
let mut item: DirectTurnStreamItem = serde_json::from_value(payload.clone()).ok()?;
if item.id.trim().is_empty() || item.turn_id.trim().is_empty() {
return None;
}
if !matches!(
item.kind.as_str(),
DIRECT_TURN_STREAM_KIND_TEXT | DIRECT_TURN_STREAM_KIND_TOOL
) {
return None;
}
if item.schema_version.trim().is_empty() {
item.schema_version = DIRECT_TURN_STREAM_SCHEMA_VERSION.to_string();
}
Some(item)
}
fn read_stream_lines(path: &Path) -> Vec<DirectTurnStreamItem> {
let Ok(file) = File::open(path) else {
return Vec::new();
};
let mut reader = BufReader::new(file);
let mut buffer = Vec::new();
let mut items = Vec::new();
loop {
buffer.clear();
match reader.read_until(b'\n', &mut buffer) {
Ok(0) => break,
// 单行解码失败(非法 UTF-8)只跳过这一行,继续读后面的行。
Ok(_) => match std::str::from_utf8(&buffer) {
Ok(line) => {
if let Some(item) = stream_item_from_line(line) {
items.push(item);
}
}
Err(_) => continue,
},
// 读 I/O 错误:无法再定位下一行边界,停止读取(已读到的照常返回)。
Err(_) => break,
}
}
items
}
/// 同一 id 的重复行合并:`seq` 取最早(位置钉死,后到的不得回退),`at` 取最早非零,
/// `updated_at` 取最大;文本只在更新(或同刻更长)的快照上替换。
fn merge_stream_snapshot(
existing: &DirectTurnStreamItem,
incoming: &DirectTurnStreamItem,
) -> DirectTurnStreamItem {
let text_len = |item: &DirectTurnStreamItem| {
item.text
.as_deref()
.map(str::chars)
.map(Iterator::count)
.unwrap_or_default()
};
// writer 保证更新时间单调;完成快照可以纠正正文,旧快照不能靠更长抢回所有权。
let take_incoming = incoming.updated_at > existing.updated_at
|| (incoming.updated_at == existing.updated_at && text_len(incoming) > text_len(existing));
let mut merged = existing.clone();
if take_incoming {
merged.text = incoming.text.clone();
}
merged.updated_at = merged.updated_at.max(incoming.updated_at);
if merged.call_id.is_none() {
merged.call_id = incoming.call_id.clone();
}
merged.seq = merged.seq.min(incoming.seq);
merged.at = [merged.at, incoming.at]
.into_iter()
.filter(|at| *at > 0)
.min()
.unwrap_or_default();
merged
}
/// 按身份归并;跨回合按起点,回合内按 seq,不能用局部 seq 判断全局新旧。
fn normalize_stream_items(items: Vec<DirectTurnStreamItem>) -> Vec<DirectTurnStreamItem> {
let mut by_id: BTreeMap<String, DirectTurnStreamItem> = BTreeMap::new();
for item in items {
let merged = match by_id.remove(&item.id) {
Some(previous) => merge_stream_snapshot(&previous, &item),
None => item,
};
by_id.insert(merged.id.clone(), merged);
}
let mut normalized = by_id.into_values().collect::<Vec<_>>();
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);
}
normalized
}
/// 锁内读改写:整文件重写(追加与就地更新混用,没有纯追加的 JSONL 语义)。
/// 文件规模由 400 条上限与 8000 字符截断兜住。
fn with_locked_stream_items<T>(
root: &Path,
mutate: impl FnOnce(&mut Vec<DirectTurnStreamItem>) -> T,
) -> Result<T, String> {
let path = turn_stream_path(root);
let _project_lock = crate::acquire_game_creator_agent_runtime_project_write_lock_with_wait(
root,
"conversation.write",
)?;
let lock = project_append_lock_for(&path)?;
let _append_guard = lock.lock("回合流写入")?;
let mut items = read_stream_lines(&path);
let outcome = mutate(&mut items);
let normalized = normalize_stream_items(items);
let mut body = String::new();
for item in &normalized {
body.push_str(&record_line(item)?);
body.push('\n');
}
write_game_creator_private_file(&path, body.as_bytes(), "回合流历史")?;
Ok(outcome)
}
/// 幂等 upsert 一条回合流条目。
///
/// 位置(`seq` / `at`)只在第一次出现时确定:同一 id 的后续快照不得回退位置,
/// 也不得把已经写下的文本改短(并发落盘下"后到的旧快照"不会覆盖新快照)。
pub(crate) fn upsert_direct_turn_stream_item_at(
root: &Path,
item: &DirectTurnStreamItem,
) -> Result<(), String> {
enforce_project_permission_policy(root, "conversation.write")?;
with_locked_stream_items(root, |items| {
// normalize_stream_items 在锁内归并全部版本;不得提前删除比较基准。
items.push(item.clone());
})
}
/// 回读:文件缺失返回空数组;单行损坏跳过;按 `seq` 正序,最多最后 400 条。
pub(crate) fn read_direct_turn_stream_at(root: &Path) -> Result<Vec<DirectTurnStreamItem>, String> {
let path = turn_stream_path(root);
if !prepare_game_creator_private_path_for_read(&path, false, "回合流历史")? {
return Ok(Vec::new());
}
Ok(normalize_stream_items(read_stream_lines(&path)))
}
/// 追加一段固定身份的文本段(失败说明等):位置排在当前流末尾。
///
/// 幂等:同一 `(turnId, itemId)` 已经存在时只更新文本与 `updatedAt`(回合重放 / 重复收尾
/// 不会多出一段)。返回写下的那一条,调用方用它下发同一份快照。
pub(crate) fn append_direct_turn_stream_text_at(
root: &Path,
turn_id: &str,
item_id: &str,
text: &str,
) -> Result<Option<DirectTurnStreamItem>, String> {
let turn_id = turn_id.trim();
let text = text.trim();
if turn_id.is_empty() || text.is_empty() {
return Ok(None);
}
let sanitized = sanitize_stream_text(root, text);
let item_id = item_id.trim();
enforce_project_permission_policy(root, "conversation.write")?;
let now = crate::agent::direct_tool_call_now_ms();
with_locked_stream_items(root, |items| {
let existing_id = direct_turn_stream_text_item_id(turn_id, item_id);
if let Some(existing) = items.iter_mut().find(|item| item.id == existing_id) {
// 位置不动:只替换文本与 updatedAt。
existing.text = Some(sanitized.clone());
existing.updated_at = now.max(existing.updated_at);
return Some(existing.clone());
}
// 首次出现:位置钉在末尾(当前最大 seq + 1)。
let next_seq = items.iter().map(|item| item.seq).max().unwrap_or(0) + 1;
let item = DirectTurnStreamItem {
schema_version: DIRECT_TURN_STREAM_SCHEMA_VERSION.to_string(),
id: existing_id,
turn_id: turn_id.to_string(),
kind: DIRECT_TURN_STREAM_KIND_TEXT.to_string(),
text: Some(sanitized),
call_id: None,
seq: next_seq,
at: now,
updated_at: now,
};
items.push(item.clone());
Some(item)
})
}
/// 没有任何 item 文本时补最终回复;已有 item 由完成事件负责,不能猜测覆盖某一段。
pub(crate) fn finalize_direct_turn_stream_reply_at(
root: &Path,
turn_id: &str,
visible_reply: &str,
) -> Result<Option<DirectTurnStreamItem>, String> {
let turn_id = turn_id.trim();
if turn_id.is_empty() || visible_reply.trim().is_empty() {
return Ok(None);
}
// 入口再做一次可见性投影:调用方给的是原始回复时,思考块不能落进对话流。
let visible_reply = crate::agent::project_direct_codex_visible_text(visible_reply)
.unwrap_or_else(|| visible_reply.trim().to_string());
let visible_reply = visible_reply.as_str();
enforce_project_permission_policy(root, "conversation.write")?;
let now = crate::agent::direct_tool_call_now_ms();
with_locked_stream_items(root, |items| {
if items
.iter()
.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)
}
})
}
@@ -49,6 +49,61 @@ impl DirectGameCreatorTurnUpdateEmitter {
status: &'static str,
activity: Option<&'static str>,
accumulated_text: Option<String>,
tool_calls: Option<Vec<crate::DirectToolCall>>,
) {
self.emit_with_reasoning(status, activity, accumulated_text, tool_calls, None);
}
/// 带思考过程的回合更新:`reasoning_text` 为"当前累计的思考全文"(前端整段替换)。
pub(crate) fn emit_with_reasoning(
&self,
status: &'static str,
activity: Option<&'static str>,
accumulated_text: Option<String>,
tool_calls: Option<Vec<crate::DirectToolCall>>,
reasoning_text: Option<String>,
) {
self.emit_full(
status,
activity,
accumulated_text,
tool_calls,
reasoning_text,
Vec::new(),
);
}
/// 带回合流的回合更新:`stream_items` 是"顺序真相"里本次变化的那几条。
///
/// 前端按这些条目的 `seq` 顺序渲染,所以它们必须来自与落盘同一份数据,
/// 不能在前端各算一套顺序。
pub(crate) fn emit_with_stream_items(
&self,
status: &'static str,
activity: Option<&'static str>,
accumulated_text: Option<String>,
tool_calls: Option<Vec<crate::DirectToolCall>>,
stream_items: Vec<crate::DirectTurnStreamItem>,
) {
self.emit_full(
status,
activity,
accumulated_text,
tool_calls,
None,
stream_items,
);
}
#[allow(clippy::too_many_arguments)]
fn emit_full(
&self,
status: &'static str,
activity: Option<&'static str>,
accumulated_text: Option<String>,
tool_calls: Option<Vec<crate::DirectToolCall>>,
reasoning_text: Option<String>,
stream_items: Vec<crate::DirectTurnStreamItem>,
) {
let status_is_allowed = matches!(
status,
@@ -100,6 +155,9 @@ impl DirectGameCreatorTurnUpdateEmitter {
status: status.to_string(),
activity: activity.map(str::to_string),
accumulated_text,
tool_calls,
reasoning_text,
stream_items: (!stream_items.is_empty()).then_some(stream_items),
updated_at,
},
);
@@ -1996,6 +1996,29 @@ pub(crate) fn write_game_creator_app_config(
persist_game_creator_app_config(config, overlays, false)
}
#[tauri::command]
pub(crate) fn cancel_direct_codex_turn(
project_path: String,
client_turn_id: Option<String>,
) -> Result<DirectTurnCancelView, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "agent.kill")?;
cancel_direct_codex_turn_at(root, client_turn_id.as_deref())
}
#[tauri::command]
pub(crate) fn select_game_creator_reasoning_effort(
effort: String,
) -> Result<GameCreatorAppConfigView, String> {
let _guard = GAME_CREATOR_CONFIG_WRITE_LOCK
.lock()
.map_err(|_| "配置写入锁不可用")?;
let effort = game_creator_llm_reasoning_effort_name(&effort, "llm.reasoningEffort")?;
let (mut config, overlays) = load_game_creator_app_config_for_write()?;
config.llm.reasoning_effort = effort;
persist_game_creator_app_config(config, overlays, false)
}
#[tauri::command]
pub(crate) fn select_game_creator_model(
model_id: String,
@@ -5283,6 +5306,31 @@ pub(crate) async fn read_agent_runtime_error_detail(
.await
.map_err(|error| format!("读取统一错误诊断后台任务失败:{error}"))?
}
#[tauri::command]
pub(crate) async fn read_direct_tool_calls(
project_path: String,
) -> Result<Vec<DirectToolCall>, String> {
tauri::async_runtime::spawn_blocking(move || {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "conversation.read")?;
read_direct_tool_calls_at(root)
})
.await
.map_err(|error| format!("读取工具调用历史后台任务失败:{error}"))?
}
#[tauri::command]
pub(crate) async fn read_direct_turn_stream(
project_path: String,
) -> Result<Vec<DirectTurnStreamItem>, String> {
tauri::async_runtime::spawn_blocking(move || {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "conversation.read")?;
read_direct_turn_stream_at(root)
})
.await
.map_err(|error| format!("读取回合流历史后台任务失败:{error}"))?
}
#[tauri::command]
pub(crate) fn list_game_creator_direct_active_turns(
@@ -5330,12 +5378,16 @@ pub(crate) async fn read_direct_project_history_slice(
tauri::async_runtime::spawn_blocking(move || {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "conversation.read")?;
let (items, has_more) = read_direct_project_history_items_slice_at(
let (items, has_more, item_timestamps) = read_direct_project_history_items_slice_at(
root,
before_item_id.as_deref(),
limit.unwrap_or(20),
)?;
Ok(DirectThreadHistorySlice { items, has_more })
Ok(DirectThreadHistorySlice {
items,
has_more,
item_timestamps,
})
})
.await
.map_err(|error| format!("读取 DirectProject 历史切片后台任务失败:{error}"))?
@@ -1010,6 +1010,17 @@ struct GameCreatorDirectTurnUpdateEvent {
status: String,
activity: Option<String>,
accumulated_text: Option<String>,
/// 本回合内发生变化的结构化工具调用集合(只有变化时才带,老事件没有这个字段)。
/// `skip_serializing_if`:字段缺席时前端拿到 `undefined`,行为与改造前一致。
#[serde(skip_serializing_if = "Option::is_none")]
tool_calls: Option<Vec<crate::DirectToolCall>>,
/// 本回合当前累计的思考过程(流式整段替换);拿不到时字段缺席。
#[serde(skip_serializing_if = "Option::is_none")]
reasoning_text: Option<String>,
/// 本回合**顺序真相**里本次发生变化的那几条(文本段 / 工具位置标记)。
/// `skip_serializing_if`:字段缺席时前端拿到 `undefined`,行为与改造前一致。
#[serde(skip_serializing_if = "Option::is_none")]
stream_items: Option<Vec<crate::DirectTurnStreamItem>>,
updated_at: u64,
}
@@ -2666,6 +2677,8 @@ fn main() {
chat_with_game_creator_role_agent,
chat_with_game_creator_role_agent_stream,
chat_with_game_creator_direct_codex,
cancel_direct_codex_turn,
select_game_creator_reasoning_effort,
start_planning_session_v2,
continue_planning_session_v2,
decide_planning_artifact_v2,
@@ -2769,6 +2782,8 @@ fn main() {
archive_game_creator_agent_session,
read_local_conversation,
read_direct_project_conversation,
read_direct_tool_calls,
read_direct_turn_stream,
read_agent_runtime_error_detail,
list_game_creator_direct_active_turns,
subscribe_direct_project_thread,
File diff suppressed because it is too large Load Diff
+111
View File
@@ -1096,6 +1096,57 @@ export type GameCreatorDirectTurnActivity =
| 'response-finalization'
| 'none';
export type GameCreatorDirectToolCallKind =
| 'command'
| 'file_change'
| 'mcp_tool'
| 'web_search'
| 'context_compaction'
| 'other';
export type GameCreatorDirectToolCallStatus =
| 'running'
| 'completed'
| 'failed';
export interface GameCreatorDirectToolCallChange {
path: string;
kind: 'add' | 'update' | 'delete' | string;
}
export interface GameCreatorDirectToolCallDetail {
command?: string;
output?: string;
changes?: GameCreatorDirectToolCallChange[];
}
/**
* 一条工具调用(Codex item 的结构化投影)。
*
* 契约见 `docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md`
* 字段形状与 Rust 侧 `DirectToolCall`、独立历史文件
* `.agent/conversations/tool-calls.jsonl` 的 payload 一致(这里少 `turnId` 的变体用于
* 事件增量,见下面 `GameCreatorDirectTurnToolCall`)。
*/
export interface GameCreatorDirectToolCall {
schemaVersion: string;
id: string;
turnId: string;
kind: GameCreatorDirectToolCallKind;
title: string;
summary: string;
status: GameCreatorDirectToolCallStatus;
detail: GameCreatorDirectToolCallDetail;
startedAt: number;
updatedAt: number;
}
/** 事件里下发的增量条目:与持久化同形,去掉 `turnId`(回合 id 在事件顶层)。 */
export type GameCreatorDirectTurnToolCall = Omit<
GameCreatorDirectToolCall,
'turnId'
>;
export interface GameCreatorDirectTurnUpdateEvent {
projectPath: string;
turnId: string;
@@ -1103,9 +1154,69 @@ export interface GameCreatorDirectTurnUpdateEvent {
status: GameCreatorDirectTurnUpdateStatus;
activity?: GameCreatorDirectTurnActivity | null;
accumulatedText?: string | null;
/**
* 本回合内**发生变化**的结构化工具调用(只有变化时才带,不是每个 heartbeat 都带全量)。
* 可选:老版本事件没有这个字段,前端拿到 `undefined` 时必须与改造前行为一致。
*/
toolCalls?: GameCreatorDirectTurnToolCall[] | null;
/**
* 本回合当前累计的思考过程(流式,整段替换);拿不到时字段缺席。
*/
reasoningText?: string | null;
/**
* 「文本段 + 工具」的**顺序真相**里本次发生变化的那几条。
*
* 顺序由 `seq`(条目首次出现时钉死)决定,与落盘 `turn-stream.jsonl` 完全同一份数据,
* 前端不再自己猜切点。可选:老版本事件没有这个字段。
*/
streamItems?: TurnStreamItem[] | null;
updatedAt: number;
}
/** 回合流里的一个 `text` 段;`text` 是该段当前累计全文(会随 delta 增长)。 */
export interface TurnStreamTextItem extends TurnStreamItemBase {
kind: 'text';
text: string;
}
/** 回合流里的一个 `tool` 位置标记;工具正文在 `tool-calls.jsonl`(按 `callId` 关联)。 */
export interface TurnStreamToolItem extends TurnStreamItemBase {
kind: 'tool';
callId: string;
}
interface TurnStreamItemBase {
schemaVersion: string;
/** 幂等身份:文本段 `text:<turnId>:<itemId>`、工具 `tool:<turnId>:<callId>`。 */
id: string;
turnId: string;
/** 首次出现的写入序号:**顺序真相**,按它升序渲染。 */
seq: number;
/** 条目首次出现的时刻(Unix 毫秒),同 `seq` 时用它排序。 */
at: number;
updatedAt: number;
}
/**
* 回合流条目(`read_direct_turn_stream` 的返回元素)。
*
* 与 Rust `DirectTurnStreamItem` 同形:`text` 段 ↔ `tool` 位置标记。
*/
export type TurnStreamItem = TurnStreamTextItem | TurnStreamToolItem;
/** `cancel_direct_codex_turn` 的返回值。 */
export interface DirectTurnCancelView {
/**
* `interrupted` = 已向正在跑的回合发出中断,界面等这一轮自己的收尾复位;
* `released` = app-server 侧已无句柄,本轮守卫被兜底释放,界面必须自己复位。
*/
outcome: string;
/** 给用户看的可读结果。 */
message: string;
/** 被终止 / 被释放的 clientTurnId。 */
clientTurnId: string;
}
export interface AgentRunControlResult {
runId: string;
status: string;
@@ -0,0 +1,37 @@
/* 只作用于共享 Markdown 渲染器,不改变普通正文及用户消息的字体颜色。 */
.agc-markdown-code .hljs-comment,
.agc-markdown-code .hljs-quote {
color: #6a737d;
}
.agc-markdown-code .hljs-keyword,
.agc-markdown-code .hljs-name,
.agc-markdown-code .hljs-selector-tag,
.agc-markdown-code .hljs-literal,
.agc-markdown-code .hljs-deletion {
color: #a6264c;
}
.agc-markdown-code .hljs-string,
.agc-markdown-code .hljs-regexp,
.agc-markdown-code .hljs-addition {
color: #276438;
}
.agc-markdown-code .hljs-number,
.agc-markdown-code .hljs-attr,
.agc-markdown-code .hljs-variable,
.agc-markdown-code .hljs-built_in {
color: #075a9c;
}
.agc-markdown-code .hljs-title,
.agc-markdown-code .hljs-type,
.agc-markdown-code .hljs-section {
color: #6f42a0;
}
.agc-markdown-code .hljs-meta,
.agc-markdown-code .hljs-symbol {
color: #8a4c0a;
}
@@ -1,3 +1,5 @@
import './codeHighlight.css';
import type { ErrorInfo, ReactNode } from 'react';
import {
Children,
@@ -7,14 +9,33 @@ import {
useContext,
} from 'react';
import ReactMarkdown, { type Components } from 'react-markdown';
import rehypeHighlight from 'rehype-highlight';
import remarkGfm from 'remark-gfm';
export type ChatMarkdownMessageProps = {
text: string;
role: 'assistant' | 'user';
streaming?: boolean;
/** 文件预览不压缩正文空行,保留源码与文档的原始排版。 */
preserveBlankLines?: boolean;
};
const MAX_HIGHLIGHT_CHARACTERS = 100_000;
const CodeBlockContext = createContext(false);
/** 只压缩普通 Markdown 正文里多余的空行;代码块中的换行必须原样保留。 */
function normalizeMarkdownBlankLines(text: string) {
return text
.replace(/\r\n?/g, '\n')
.split(/(```[\s\S]*?```)/g)
.map((part, index) =>
index % 2 === 1
? part
: part.replace(/[ \t]*\n(?:[ \t]*\n){2,}/g, '\n\n'),
)
.join('');
}
type MarkdownErrorBoundaryProps = {
fallbackText: string;
children: ReactNode;
@@ -66,7 +87,6 @@ export class MarkdownErrorBoundary extends Component<
}
const ListDepthContext = createContext(0);
const ListKindContext = createContext<'unordered' | 'ordered' | null>(null);
type ListItemParagraphPosition = 'first' | 'continuation';
const ListItemContext = createContext<ListItemParagraphPosition | null>(null);
@@ -75,15 +95,11 @@ function MarkdownUnorderedList({ children }: { children?: ReactNode }) {
const depth = useContext(ListDepthContext);
return (
<ListDepthContext.Provider value={depth + 1}>
<ListKindContext.Provider value="unordered">
<ul
className={`m-0 mt-2 list-none space-y-1 first:mt-0 ${
depth > 0 ? 'pl-4' : 'pl-0'
}`}
>
{children}
</ul>
</ListKindContext.Provider>
{/* 用真正的列表标记(`list-disc`)而不是手写 `'- '` 文本:手写前缀既没有悬挂缩进
(换行后的第二行会顶回最左边),也不算列表语义(读屏读成普通文本)。 */}
<ul className="m-0 mt-2 list-disc space-y-1 pl-4 first:mt-0">
{children}
</ul>
</ListDepthContext.Provider>
);
}
@@ -98,14 +114,12 @@ function MarkdownOrderedList({
const depth = useContext(ListDepthContext);
return (
<ListDepthContext.Provider value={depth + 1}>
<ListKindContext.Provider value="ordered">
<ol
start={start}
className="m-0 mt-2 list-decimal space-y-1 pl-5 first:mt-0"
>
{children}
</ol>
</ListKindContext.Provider>
<ol
start={start}
className="m-0 mt-2 list-decimal space-y-1 pl-5 first:mt-0"
>
{children}
</ol>
</ListDepthContext.Provider>
);
}
@@ -145,7 +159,6 @@ function StreamingMarkdownParagraph({ children }: { children?: ReactNode }) {
}
function MarkdownListItem({ children }: { children?: ReactNode }) {
const listKind = useContext(ListKindContext);
let paragraphIndex = 0;
const childrenWithParagraphContext = Children.map(
children,
@@ -168,7 +181,6 @@ function MarkdownListItem({ children }: { children?: ReactNode }) {
);
return (
<li className="break-words whitespace-normal">
{listKind === 'unordered' ? '- ' : null}
{childrenWithParagraphContext}
</li>
);
@@ -179,22 +191,50 @@ const markdownComponents: Components = {
a: ({ children }) => children,
img: ({ alt }) => (alt?.trim() ? `图片:${alt}` : '图片已省略'),
h1: ({ children }) => (
<h1 className="m-0 mt-4 !text-xl font-bold first:mt-0">{children}</h1>
<h1
className="m-0 mt-4 font-bold first:mt-0"
style={{ fontSize: 'var(--agent-message-heading-size, 1.25rem)' }}
>
{children}
</h1>
),
h2: ({ children }) => (
<h2 className="m-0 mt-4 !text-lg font-bold first:mt-0">{children}</h2>
<h2
className="m-0 mt-4 font-bold first:mt-0"
style={{ fontSize: 'var(--agent-message-heading-size, 1.125rem)' }}
>
{children}
</h2>
),
h3: ({ children }) => (
<h3 className="m-0 mt-3 !text-base font-semibold first:mt-0">{children}</h3>
<h3
className="m-0 mt-3 font-semibold first:mt-0"
style={{ fontSize: 'var(--agent-message-heading-size, 1rem)' }}
>
{children}
</h3>
),
h4: ({ children }) => (
<h4 className="m-0 mt-3 !text-sm font-semibold first:mt-0">{children}</h4>
<h4
className="m-0 mt-3 font-semibold first:mt-0"
style={{ fontSize: 'var(--agent-message-heading-size, 0.875rem)' }}
>
{children}
</h4>
),
h5: ({ children }) => (
<h5 className="m-0 mt-2 !text-sm font-medium first:mt-0">{children}</h5>
<h5
className="m-0 mt-2 font-medium first:mt-0"
style={{ fontSize: 'var(--agent-message-heading-size, 0.875rem)' }}
>
{children}
</h5>
),
h6: ({ children }) => (
<h6 className="m-0 mt-2 !text-xs font-medium uppercase tracking-wide first:mt-0">
<h6
className="m-0 mt-2 font-medium uppercase tracking-wide first:mt-0"
style={{ fontSize: 'var(--agent-message-heading-size, 0.75rem)' }}
>
{children}
</h6>
),
@@ -209,15 +249,18 @@ const markdownComponents: Components = {
),
pre: ({ children }) => (
<pre className="m-0 mt-2 max-w-full overflow-x-auto rounded-lg bg-black/6 p-3 text-xs leading-5 first:mt-0">
{children}
<CodeBlockContext.Provider value={true}>
{children}
</CodeBlockContext.Provider>
</pre>
),
code: ({ className, children, node: _node, ...props }) => {
const isBlock =
Boolean(className?.includes('language-')) ||
String(children).includes('\n');
code: function MarkdownCode({ className, children, node: _node, ...props }) {
const isBlock = useContext(CodeBlockContext);
return isBlock ? (
<code {...props} className="font-mono whitespace-pre">
<code
{...props}
className={`agc-markdown-code font-mono whitespace-pre ${className ?? ''}`}
>
{children}
</code>
) : (
@@ -231,7 +274,10 @@ const markdownComponents: Components = {
},
table: ({ children }) => (
<div className="mt-2 max-w-full overflow-x-auto first:mt-0">
<table className="min-w-full border-collapse text-left text-sm">
<table
className="min-w-full border-collapse text-left"
style={{ fontSize: 'var(--agent-message-table-size, 0.875rem)' }}
>
{children}
</table>
</div>
@@ -260,6 +306,7 @@ export function ChatMarkdownMessage({
text,
role,
streaming = false,
preserveBlankLines = false,
}: ChatMarkdownMessageProps) {
if (role === 'user') {
return <span className="whitespace-pre-wrap break-words">{text}</span>;
@@ -270,11 +317,14 @@ export function ChatMarkdownMessage({
<ReactMarkdown
skipHtml
remarkPlugins={[remarkGfm]}
rehypePlugins={
text.length <= MAX_HIGHLIGHT_CHARACTERS ? [rehypeHighlight] : []
}
components={
streaming ? streamingMarkdownComponents : markdownComponents
}
>
{text}
{preserveBlankLines ? text : normalizeMarkdownBlankLines(text)}
</ReactMarkdown>
</MarkdownErrorBoundary>
);
@@ -1327,12 +1327,9 @@ export function isMissingAgentGoalCommandError(error: unknown) {
}
export function createDefaultChatMessages(): ChatMessage[] {
return [
{
role: 'assistant',
text: '想做什么游戏?',
},
];
// 默认问候「想做什么游戏?」已移除:它在对话记录里没有信息量,而且会出现在用户消息之后。
// 空对话由空状态提示(panels.tsx 的引导文案)承担,不再往消息列表里塞占位消息。
return [];
}
export function isRuntimeConfigMissingError(message: string) {
@@ -73,7 +73,13 @@ export function AccountWalletDialogs({
>
<header className="launcher-redeem-modal-header">
<strong></strong>
<button type="button" onClick={controller.closeRedeemCode} aria-label="关闭兑换码">×</button>
<button
type="button"
onClick={controller.closeRedeemCode}
aria-label="关闭兑换码"
>
×
</button>
</header>
<form
className="launcher-redeem-modal-form"
@@ -84,14 +90,25 @@ export function AccountWalletDialogs({
>
<input
value={controller.redeemCodeInput}
onChange={(event) => controller.setRedeemCodeInput(event.target.value)}
onChange={(event) =>
controller.setRedeemCodeInput(event.target.value)
}
placeholder="输入兑换码"
aria-label="兑换码"
autoFocus
/>
{controller.redeemCodeError ? <p role="alert">{controller.redeemCodeError}</p> : null}
{controller.redeemCodeSuccess ? <p role="status">{controller.redeemCodeSuccess}</p> : null}
<button type="submit" disabled={controller.redeemCodeLoading || !controller.redeemCodeInput.trim()}>
{controller.redeemCodeError ? (
<p role="alert">{controller.redeemCodeError}</p>
) : null}
{controller.redeemCodeSuccess ? (
<p role="status">{controller.redeemCodeSuccess}</p>
) : null}
<button
type="submit"
disabled={
controller.redeemCodeLoading || !controller.redeemCodeInput.trim()
}
>
{controller.redeemCodeLoading ? '兑换中' : '兑换'}
</button>
</form>
@@ -181,7 +181,10 @@ export function ActiveProjectRunsPanel({
disabled={!onOpenProject}
onClick={() => openProject(turn.projectPath)}
>
<span className="launcher-runs-titlebar-item-name" title={name}>
<span
className="launcher-runs-titlebar-item-name"
title={name}
>
{name}
</span>
<span className="launcher-runs-titlebar-item-meta">
@@ -50,7 +50,9 @@ export function useAccountWallet(currentUserId: string) {
const [redeemCodeInput, setRedeemCodeInput] = useState('');
const [redeemCodeLoading, setRedeemCodeLoading] = useState(false);
const [redeemCodeError, setRedeemCodeError] = useState<string | null>(null);
const [redeemCodeSuccess, setRedeemCodeSuccess] = useState<string | null>(null);
const [redeemCodeSuccess, setRedeemCodeSuccess] = useState<string | null>(
null,
);
const rechargeLifecycleRef = useRef(0);
const walletLedgerLifecycleRef = useRef(0);
const redeemLifecycleRef = useRef(0);
@@ -251,16 +253,26 @@ export function useAccountWallet(currentUserId: string) {
setRedeemCodeSuccess(null);
try {
const response = await redeemClientProfileRewardCode(code);
if (redeemLifecycleRef.current !== lifecycle || currentUserIdRef.current !== owner) return;
if (
redeemLifecycleRef.current !== lifecycle ||
currentUserIdRef.current !== owner
)
return;
setRedeemCodeSuccess(`兑换成功,已到账 ${response.amountGranted} 泥点`);
setRedeemCodeInput('');
void onWalletBalanceMayHaveChanged();
} catch (error) {
if (redeemLifecycleRef.current === lifecycle && currentUserIdRef.current === owner) {
if (
redeemLifecycleRef.current === lifecycle &&
currentUserIdRef.current === owner
) {
setRedeemCodeError(error instanceof Error ? error.message : '兑换失败');
}
} finally {
if (redeemLifecycleRef.current === lifecycle && currentUserIdRef.current === owner) {
if (
redeemLifecycleRef.current === lifecycle &&
currentUserIdRef.current === owner
) {
setRedeemCodeLoading(false);
}
}
@@ -36,6 +36,7 @@ import type {
HomeCreationType,
HomeDraft,
} from '../../view/home';
import { richTextToPrompt } from '../../view/home/components/RichInputArea/richTextToPrompt';
import { useLauncherHomeDraftStore } from '../../view/home/useHomeDraftStore';
import type { LauncherView } from '../../view/layout';
import type {
@@ -49,6 +50,15 @@ import {
} from '../project-summary/projectSummary';
import { resolveSessionPreviewOnProjectOpen } from './sessionPreview';
/** 首页输入框当前的纯文本(Lexical 编辑器状态 -> 文本);没有输入就返回空串。 */
function homeDraftPromptText() {
try {
return richTextToPrompt(useLauncherHomeDraftStore.getState().draft).trim();
} catch {
return '';
}
}
type UseHomeProjectCreationOptions = {
setStatus: Dispatch<SetStateAction<string>>;
setLauncherView: Dispatch<SetStateAction<LauncherView>>;
@@ -92,6 +102,51 @@ async function suggestAutomaticProjectName(
}
}
/**
* 把浏览器 File 上传进项目并登记为资产,返回带项目相对路径的附件记录。
*
* 首页建项目与右侧对话输入盒共用同一条链路:`upload_local_asset` 写进项目之后,
* 附件才能以「项目路径」形式进入回合附件(绝对路径会被 Rust 侧的附件脱敏规则拒绝)。
*/
export async function uploadLocalFilesAsAttachments(
invoke: TauriInvoke,
nextProjectPath: string,
files: readonly File[],
): Promise<LauncherImportedAttachment[]> {
const imported: LauncherImportedAttachment[] = [];
for (const file of files) {
const mediaType = file.type || 'application/octet-stream';
try {
const bytes = Array.from(new Uint8Array(await file.arrayBuffer()));
const result = await invoke<UploadLocalAssetResult>(
'upload_local_asset',
{
projectPath: nextProjectPath,
fileName: file.name,
mediaType,
bytes,
},
);
imported.push({
fileName: file.name,
mediaType,
localPath: result.localPath,
status: 'imported',
size: file.size,
});
} catch (error) {
imported.push({
fileName: file.name,
mediaType,
status: 'failed',
error: error instanceof Error ? error.message : String(error),
size: file.size,
});
}
}
return imported;
}
/**
* 自动建项的兜底期限。
*
@@ -304,40 +359,11 @@ export function useHomeProjectCreation({
nextProjectPath: string,
attachments: HomeAttachmentDraft[],
) {
const imported: LauncherImportedAttachment[] = [];
for (const attachment of attachments) {
const mediaType = attachment.file.type || 'application/octet-stream';
try {
const bytes = Array.from(
new Uint8Array(await attachment.file.arrayBuffer()),
);
const result = await invoke<UploadLocalAssetResult>(
'upload_local_asset',
{
projectPath: nextProjectPath,
fileName: attachment.file.name,
mediaType,
bytes,
},
);
imported.push({
fileName: attachment.file.name,
mediaType,
localPath: result.localPath,
status: 'imported',
size: attachment.file.size,
});
} catch (error) {
imported.push({
fileName: attachment.file.name,
mediaType,
status: 'failed',
error: error instanceof Error ? error.message : String(error),
size: attachment.file.size,
});
}
}
return imported;
return uploadLocalFilesAsAttachments(
invoke,
nextProjectPath,
attachments.map((attachment) => attachment.file),
);
}
async function enterCreatedHomeProject(
@@ -455,6 +481,9 @@ export function useHomeProjectCreation({
async function createProjectFromProjectPage(
nextProjectPath: string,
skipNonEmptyCheck = false,
// 首页输入框里已经写好的要求:打开已有项目时不能再丢掉(此前写死空串,
// 用户写的内容既不发首轮也不进对话历史)。
initialPrompt = '',
) {
if (projectActionRef.current) {
return;
@@ -507,7 +536,7 @@ export function useHomeProjectCreation({
),
creationType: null,
startMode: null,
initialPrompt: '',
initialPrompt,
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
@@ -613,7 +642,7 @@ export function useHomeProjectCreation({
),
creationType: null,
startMode: runtimeMode?.activeRuntime === 'design' ? 'planning' : null,
initialPrompt: '',
initialPrompt: homeDraftPromptText(),
attachments: [],
recentRunStatus: directoryStatus.recentRunStatus,
recentRunStopReason: directoryStatus.recentRunStopReason,
@@ -906,7 +935,11 @@ export function useHomeProjectCreation({
setProjectPath(selectedPath);
projectActionRef.current = null;
setProjectAction(null);
await createProjectFromProjectPage(selectedPath);
await createProjectFromProjectPage(
selectedPath,
false,
homeDraftPromptText(),
);
} catch (error) {
setStatus(error instanceof Error ? error.message : String(error));
} finally {
File diff suppressed because it is too large Load Diff
@@ -356,8 +356,8 @@ export function ConversationModelSelect({
aria-hidden="true"
/>
<span className="conversation-model-trigger-label">
{models.find((model) => model.id === selected)?.displayName ??
(busy ? '正在读取模型' : '选择模型')}
{/* 控件位只显示已选模型名;目录还没读完时用「模型」占位(加载提示留在菜单里)。 */}
{models.find((model) => model.id === selected)?.displayName ?? '模型'}
</span>
<ChevronDown size={13} aria-hidden="true" />
</button>
@@ -0,0 +1,116 @@
import { Settings2, ShieldCheck, Wallet, X } from 'lucide-react';
import type { ReactNode } from 'react';
import { useState } from 'react';
import {
closeDialogOnBackdropMouseDown,
useEscapeToClose,
} from '../../app/dialogs';
import { RuntimeConfigDialog } from '../runtime-config/RuntimeConfigDialog';
/**
* 右侧对话面板 Codex 风格改造后,面板顶部只剩「状态点 + 状态文案 + 齿轮」。
* 原来长在面板顶部(以及 `view/project-development/index.tsx` 头部)的
* 运行配置 / 审批配置 / 泥点钱包统一收进这个独立浮层:点齿轮才出现,
* 不再往面板下面追加内容。
*
* 组成:
* - 「运行配置」打开既有 `RuntimeConfigDialog`(独立浮层,自己有 backdrop);
* - 「操作权限」打开由 `ApprovalModeDialog` 提供的选择面板;
* - 「泥点」直接渲染父级传入的 `walletEntry`(为空则整行不渲染)。
*/
export function ProjectSupervisorSettingsDialog({
projectPath,
currentApprovalLabel,
onOpenApproval,
walletEntry,
onClose,
}: {
projectPath: string;
currentApprovalLabel: string;
onOpenApproval: () => void;
walletEntry?: ReactNode;
onClose: () => void;
}) {
const [runtimeConfigOpen, setRuntimeConfigOpen] = useState(false);
// 二级浮层自己处理 Esc;否则一次 Esc 会把两层一起关掉。
useEscapeToClose(onClose, !runtimeConfigOpen);
return (
<>
<div
className="project-supervisor-settings-backdrop"
role="presentation"
onMouseDown={(event) => closeDialogOnBackdropMouseDown(event, onClose)}
>
<section
className="project-supervisor-settings-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="project-supervisor-settings-title"
>
<header>
<div>
<h2 id="project-supervisor-settings-title"></h2>
<small></small>
</div>
<button
type="button"
className="project-supervisor-settings-close"
aria-label="关闭设置"
onClick={onClose}
>
<X size={16} aria-hidden="true" />
</button>
</header>
<div className="project-supervisor-settings-rows">
<button
type="button"
className="project-supervisor-settings-row"
onClick={() => setRuntimeConfigOpen(true)}
>
<span className="project-supervisor-settings-row-main">
<Settings2 size={16} aria-hidden="true" />
<span>
<strong></strong>
<small>Agent </small>
</span>
</span>
</button>
<button
type="button"
className="project-supervisor-settings-row"
aria-label={`审批配置,当前${currentApprovalLabel}`}
onClick={onOpenApproval}
>
<span className="project-supervisor-settings-row-main">
<ShieldCheck size={16} aria-hidden="true" />
<span>
<strong></strong>
<small>{currentApprovalLabel}</small>
</span>
</span>
</button>
{walletEntry ? (
<div className="project-supervisor-settings-row is-static">
<span className="project-supervisor-settings-row-main">
<Wallet size={16} aria-hidden="true" />
<span>
<strong></strong>
<small></small>
</span>
</span>
<div className="game-workbench-chat-wallet">{walletEntry}</div>
</div>
) : null}
</div>
</section>
</div>
{runtimeConfigOpen ? (
<RuntimeConfigDialog
projectPath={projectPath}
onClose={() => setRuntimeConfigOpen(false)}
/>
) : null}
</>
);
}

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