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
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:
@@ -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,
|
||||
¶ms,
|
||||
);
|
||||
// 工具调用卡片: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(¶ms);
|
||||
@@ -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(¶ms);
|
||||
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
@@ -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
+2
-2
@@ -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>
|
||||
|
||||
+116
@@ -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
Reference in New Issue
Block a user