合并master并对齐Direct回合协议
合并主线Thread Manager订阅和历史分页能力 保留对话工具卡片、流式输出和回合终止恢复 发送队列携带结构化用户内容,附件接入规范用户消息
This commit is contained in:
@@ -164,6 +164,7 @@ module.exports = {
|
||||
'server-rs/target-*',
|
||||
'apps/desktop-shell/src-tauri/target',
|
||||
'apps/ai-game-creator-shell/src/features/ui-editor/types/**',
|
||||
'apps/ai-game-creator-shell/src/features/project-workspace/generated/**',
|
||||
'target',
|
||||
'src/main.tsx',
|
||||
'src/App.tsx',
|
||||
|
||||
@@ -16,14 +16,15 @@ mod design_runtime;
|
||||
mod design_tools;
|
||||
mod direct_codex_attachments;
|
||||
mod direct_codex_audit;
|
||||
mod direct_codex_references;
|
||||
mod direct_codex_user_item;
|
||||
mod direct_project_history;
|
||||
mod direct_project_turn_history;
|
||||
mod direct_runtime;
|
||||
mod direct_thread_manager;
|
||||
mod direct_tool_bridge;
|
||||
mod direct_tool_calls;
|
||||
mod direct_turn_stream;
|
||||
mod direct_tools_mcp;
|
||||
mod direct_turn_stream;
|
||||
mod generation;
|
||||
mod interaction;
|
||||
mod prompt;
|
||||
@@ -37,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_game_creator_codex_chat_at,
|
||||
direct_game_creator_home_codex_chat, DirectTurnCancelView,
|
||||
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, DirectTurnCancelView,
|
||||
};
|
||||
use codex_cli::*;
|
||||
pub(crate) use codex_cli::{
|
||||
@@ -48,14 +50,15 @@ pub(crate) use codex_provider_proxy::*;
|
||||
pub(crate) use design_runtime::*;
|
||||
pub(crate) use direct_codex_attachments::*;
|
||||
pub(crate) use direct_codex_audit::*;
|
||||
pub(crate) use direct_codex_references::*;
|
||||
pub(crate) use direct_codex_user_item::*;
|
||||
pub(crate) use direct_project_history::*;
|
||||
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_turn_stream::*;
|
||||
pub(crate) use direct_tools_mcp::*;
|
||||
pub(crate) use direct_turn_stream::*;
|
||||
pub(crate) use generation::*;
|
||||
pub(crate) use interaction::*;
|
||||
pub(crate) use prompt::*;
|
||||
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
//! DirectProject 历史注入载荷的单一构造 seam。
|
||||
//!
|
||||
//! 历史读取与大小前置校验集中在这里;调用方只负责线程生命周期与 RPC 传输。
|
||||
|
||||
use super::super::*;
|
||||
use super::direct_project_history_injection_oversize_error;
|
||||
use serde_json::Value;
|
||||
use std::path::Path;
|
||||
|
||||
pub(super) fn build_direct_project_history_injection_params(
|
||||
history_root: &Path,
|
||||
thread_id: &str,
|
||||
) -> Result<Value, platform_llm::LlmError> {
|
||||
let canonical_items = read_direct_project_history_items_at(history_root)
|
||||
.map_err(platform_llm::LlmError::InvalidRequest)?;
|
||||
let items = canonical_items
|
||||
.iter()
|
||||
.map(|item| {
|
||||
direct_codex_user_item_to_response_item(history_root, item)
|
||||
.map_err(platform_llm::LlmError::InvalidRequest)
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let params = serde_json::json!({"threadId": thread_id, "items": items});
|
||||
let payload_bytes = serde_json::to_vec(¶ms)
|
||||
.map(|bytes| bytes.len().saturating_add(1))
|
||||
.unwrap_or(usize::MAX);
|
||||
// 注入前的前置校验:失败关闭并指名 itemId 与字节数,**不截断、不摘要、不改写**。
|
||||
if let Some(error) = direct_project_history_injection_oversize_error(¶ms, payload_bytes) {
|
||||
return Err(platform_llm::LlmError::InvalidRequest(error));
|
||||
}
|
||||
Ok(params)
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
//! DirectProject 线程池身份的 canonical 解析与摘要。
|
||||
|
||||
use super::super::*;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
pub(crate) fn direct_codex_canonical_project_identity(
|
||||
root: &std::path::Path,
|
||||
) -> Result<(std::path::PathBuf, String), String> {
|
||||
let (canonical_root, _) = resolve_direct_codex_project_authority(root)?;
|
||||
let manifest = read_manifest(&canonical_root.join(".agent/manifest.json"))
|
||||
.map_err(|error| format!("读取 DirectProject 权威项目身份失败:{error}"))?;
|
||||
let manifest_project_id = manifest.project_id.trim();
|
||||
if manifest_project_id.is_empty() || manifest_project_id.chars().count() > 256 {
|
||||
return Err("DirectProject manifest.projectId 不满足身份边界".to_string());
|
||||
}
|
||||
let path_identity = direct_codex_os_path_identity_bytes(&canonical_root);
|
||||
Ok((
|
||||
canonical_root,
|
||||
direct_codex_project_identity_digest(&path_identity, manifest_project_id.as_bytes()),
|
||||
))
|
||||
}
|
||||
|
||||
pub(super) fn direct_codex_os_path_identity_bytes(path: &std::path::Path) -> Vec<u8> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
return path.as_os_str().as_bytes().to_vec();
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
let mut bytes = Vec::new();
|
||||
for unit in path.as_os_str().encode_wide() {
|
||||
bytes.extend_from_slice(&unit.to_le_bytes());
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
path.as_os_str().to_string_lossy().as_bytes().to_vec()
|
||||
}
|
||||
|
||||
pub(super) fn direct_codex_project_identity_digest(
|
||||
path_identity: &[u8],
|
||||
project_id: &[u8],
|
||||
) -> String {
|
||||
let mut digest = Sha256::new();
|
||||
digest.update(b"genarrative-direct-project-identity.v1\0");
|
||||
digest.update((path_identity.len() as u64).to_le_bytes());
|
||||
digest.update(path_identity);
|
||||
digest.update((project_id.len() as u64).to_le_bytes());
|
||||
digest.update(project_id);
|
||||
format!("{:x}", digest.finalize())
|
||||
}
|
||||
+188
-75
@@ -10,6 +10,12 @@ use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufR
|
||||
use tokio::sync::{mpsc, oneshot, Mutex, Notify};
|
||||
use uuid::Uuid;
|
||||
|
||||
mod direct_project_history_wire;
|
||||
use direct_project_history_wire::build_direct_project_history_injection_params;
|
||||
mod direct_project_identity;
|
||||
pub(crate) use direct_project_identity::direct_codex_canonical_project_identity as direct_codex_canonical_project_identity_for_commands;
|
||||
use direct_project_identity::*;
|
||||
|
||||
const GAME_CREATOR_CODEX_APP_SERVER_PROVIDER_ID: &str = "genarrative_agc";
|
||||
const GAME_CREATOR_CODEX_APP_SERVER_API_KEY_ENV: &str = "GENARRATIVE_AGC_CODEX_API_KEY";
|
||||
const GAME_CREATOR_CODEX_APP_SERVER_REMOTE_CONTROL_DISABLED_ENV: &str =
|
||||
@@ -564,6 +570,10 @@ enum CodexTurnEvent {
|
||||
completed: bool,
|
||||
params: serde_json::Value,
|
||||
},
|
||||
Request {
|
||||
event_type: &'static str,
|
||||
params: serde_json::Value,
|
||||
},
|
||||
RawItem(serde_json::Value),
|
||||
Terminal(serde_json::Value),
|
||||
TransportClosed(String),
|
||||
@@ -730,6 +740,25 @@ fn direct_codex_safe_activity_for_item_value(item: &serde_json::Value) -> &'stat
|
||||
direct_codex_safe_activity_for_item(item_type)
|
||||
}
|
||||
|
||||
/// Project an app-server item into the small public payload carried by the
|
||||
/// DirectProject event queue. Full item contents are persisted in JSONL and
|
||||
/// must not be forwarded through the runtime event stream.
|
||||
fn direct_thread_item_started_payload(item: &serde_json::Value) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"itemType": item
|
||||
.get("type")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or("unknown"),
|
||||
})
|
||||
}
|
||||
|
||||
fn direct_thread_item_id(item: &serde_json::Value) -> Option<String> {
|
||||
item.get("id")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
fn direct_codex_command_is_game_verification(command: &str) -> bool {
|
||||
let command = command.to_ascii_lowercase();
|
||||
command.contains("game.static_smoke")
|
||||
@@ -934,7 +963,7 @@ fn direct_codex_safe_activity_for_notification(method: &str) -> Option<&'static
|
||||
| "item/reasoning/summaryTextDelta"
|
||||
| "item/reasoning/summaryPartAdded"
|
||||
| "item/reasoning/textDelta" => Some("preparing"),
|
||||
"item/mcpToolCall/progress" | "serverRequest/resolved" => Some("controlled-tool"),
|
||||
"item/mcpToolCall/progress" => Some("controlled-tool"),
|
||||
"item/fileChange/outputDelta" | "item/fileChange/patchUpdated" => Some("file-write"),
|
||||
"command/exec/outputDelta"
|
||||
| "process/outputDelta"
|
||||
@@ -944,6 +973,23 @@ fn direct_codex_safe_activity_for_notification(method: &str) -> Option<&'static
|
||||
}
|
||||
}
|
||||
|
||||
fn direct_codex_request_event_type(method: &str) -> Option<&'static str> {
|
||||
match method {
|
||||
"item/fileChange/requestApproval"
|
||||
| "item/commandExecution/requestApproval"
|
||||
| "item/permissions/requestApproval" => Some("approval.requested"),
|
||||
"item/tool/requestUserInput" | "item/mcpToolCall/requestUserInput" => Some("ask.requested"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn direct_codex_resolution_event_type(method: &str) -> Option<&'static str> {
|
||||
match method {
|
||||
"serverRequest/resolved" => Some("request.resolved"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn direct_codex_intermediate_text_for_notification(
|
||||
method: &str,
|
||||
params: &serde_json::Value,
|
||||
@@ -2714,6 +2760,7 @@ impl CodexAppServerConnection {
|
||||
request,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
on_agent_message_delta,
|
||||
direct_observer,
|
||||
audit,
|
||||
@@ -2728,6 +2775,7 @@ impl CodexAppServerConnection {
|
||||
request: LlmRunRequest,
|
||||
direct_history_root: Option<&std::path::Path>,
|
||||
direct_client_turn_id: Option<&str>,
|
||||
direct_user_item: Option<&serde_json::Value>,
|
||||
mut on_agent_message_delta: Option<&mut (dyn FnMut(&platform_llm::LlmStreamDelta) + Send)>,
|
||||
mut direct_observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>,
|
||||
mut audit: Option<&mut DirectCodexTurnAudit>,
|
||||
@@ -2749,12 +2797,19 @@ impl CodexAppServerConnection {
|
||||
));
|
||||
}
|
||||
if let Some(client_turn_id) = direct_client_turn_id {
|
||||
let user_item = direct_project_local_message_item(
|
||||
"user",
|
||||
current_prompt,
|
||||
Some(&format!("direct-codex:{client_turn_id}:user")),
|
||||
)
|
||||
.map_err(platform_llm::LlmError::InvalidRequest)?;
|
||||
let user_item = match direct_user_item {
|
||||
Some(item) => {
|
||||
direct_codex_user_item_to_response_item(history_root, item)
|
||||
.map_err(platform_llm::LlmError::InvalidRequest)?;
|
||||
item.clone()
|
||||
}
|
||||
None => direct_project_local_message_item(
|
||||
"user",
|
||||
current_prompt,
|
||||
Some(&format!("direct-codex:{client_turn_id}:user")),
|
||||
)
|
||||
.map_err(platform_llm::LlmError::InvalidRequest)?,
|
||||
};
|
||||
append_direct_project_user_message_at(history_root, &user_item)
|
||||
.map_err(platform_llm::LlmError::InvalidRequest)?;
|
||||
}
|
||||
@@ -2764,24 +2819,14 @@ impl CodexAppServerConnection {
|
||||
let thread_id = thread_lease.thread_id.clone();
|
||||
if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
|
||||
if thread_created {
|
||||
let items = match read_direct_project_history_items_at(history_root) {
|
||||
Ok(items) => items,
|
||||
Err(error) => {
|
||||
self.release_thread(snapshot, &thread_id).await;
|
||||
return Err(platform_llm::LlmError::InvalidRequest(error));
|
||||
}
|
||||
};
|
||||
let params = serde_json::json!({"threadId": thread_id.clone(), "items": items});
|
||||
let payload_bytes = serde_json::to_vec(¶ms)
|
||||
.map(|bytes| bytes.len().saturating_add(1))
|
||||
.unwrap_or(usize::MAX);
|
||||
// 注入前的前置校验:失败关闭并指名 itemId 与字节数,**不截断、不摘要、不改写**。
|
||||
if let Some(error) =
|
||||
direct_project_history_injection_oversize_error(¶ms, payload_bytes)
|
||||
{
|
||||
self.release_thread(snapshot, &thread_id).await;
|
||||
return Err(platform_llm::LlmError::InvalidRequest(error));
|
||||
}
|
||||
let params =
|
||||
match build_direct_project_history_injection_params(history_root, &thread_id) {
|
||||
Ok(params) => params,
|
||||
Err(error) => {
|
||||
self.release_thread(snapshot, &thread_id).await;
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
if let Err(error) = self.request("thread/inject_items", params).await {
|
||||
self.release_thread(snapshot, &thread_id).await;
|
||||
return Err(platform_llm::LlmError::Transport(error));
|
||||
@@ -2878,6 +2923,21 @@ impl CodexAppServerConnection {
|
||||
}
|
||||
};
|
||||
turn_start_guard.armed = false;
|
||||
let direct_thread_id = history_root.to_string_lossy().into_owned();
|
||||
if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
|
||||
append_direct_thread_event(
|
||||
&direct_thread_id,
|
||||
DirectThreadRawEventDraft {
|
||||
event_type: "turn.started".to_string(),
|
||||
turn_id: turn_id.clone(),
|
||||
item_id: None,
|
||||
payload: serde_json::json!({
|
||||
"threadId": thread_id,
|
||||
"turnId": turn_id,
|
||||
}),
|
||||
},
|
||||
);
|
||||
}
|
||||
let mut receiver = self.register_turn(&turn_id).await;
|
||||
let mut direct_project_history = DirectProjectHistoryAccumulator::default();
|
||||
let mut guard = CodexTurnGuard {
|
||||
@@ -2929,6 +2989,15 @@ impl CodexAppServerConnection {
|
||||
Some(CodexTurnEvent::AgentMessageDelta { item_id, delta }) => {
|
||||
if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
|
||||
direct_project_history.observe_delta(&item_id, &delta);
|
||||
append_direct_thread_event(
|
||||
&direct_thread_id,
|
||||
DirectThreadRawEventDraft {
|
||||
event_type: "item.delta".to_string(),
|
||||
turn_id: turn_id.clone(),
|
||||
item_id: Some(item_id.clone()),
|
||||
payload: serde_json::json!({ "delta": delta.clone() }),
|
||||
},
|
||||
);
|
||||
}
|
||||
streamed_text.push_str(&delta);
|
||||
if let Some(observer) = direct_observer.as_deref_mut() {
|
||||
@@ -2982,6 +3051,37 @@ impl CodexAppServerConnection {
|
||||
})?
|
||||
.map_err(platform_llm::LlmError::InvalidRequest)?;
|
||||
direct_project_history.complete_item(&item);
|
||||
let item_id = direct_thread_item_id(&item);
|
||||
append_direct_thread_event(
|
||||
&direct_thread_id,
|
||||
DirectThreadRawEventDraft {
|
||||
event_type: "item.completed".to_string(),
|
||||
turn_id: turn_id.clone(),
|
||||
item_id,
|
||||
payload: serde_json::json!({}),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Some(CodexTurnEvent::Request { event_type, params }) => {
|
||||
if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
|
||||
let request_id = params
|
||||
.get("requestId")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.or_else(|| params.get("id").and_then(serde_json::Value::as_str))
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string);
|
||||
append_direct_thread_event(
|
||||
&direct_thread_id,
|
||||
DirectThreadRawEventDraft {
|
||||
event_type: event_type.to_string(),
|
||||
turn_id: turn_id.clone(),
|
||||
item_id: None,
|
||||
payload: request_id
|
||||
.map(|id| serde_json::json!({ "requestId": id }))
|
||||
.unwrap_or_else(|| serde_json::json!({})),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Some(CodexTurnEvent::Activity(activity)) => {
|
||||
@@ -3066,6 +3166,21 @@ impl CodexAppServerConnection {
|
||||
self.inner.workspace_mode.passive_item_boundary_name(),
|
||||
)));
|
||||
}
|
||||
if !completed
|
||||
&& self.inner.workspace_mode
|
||||
== CodexAppServerWorkspaceMode::DirectProject
|
||||
{
|
||||
let item_id = direct_thread_item_id(item);
|
||||
append_direct_thread_event(
|
||||
&direct_thread_id,
|
||||
DirectThreadRawEventDraft {
|
||||
event_type: "item.started".to_string(),
|
||||
turn_id: turn_id.clone(),
|
||||
item_id,
|
||||
payload: direct_thread_item_started_payload(item),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(CodexTurnEvent::Terminal(params)) => {
|
||||
@@ -3082,11 +3197,24 @@ impl CodexAppServerConnection {
|
||||
})
|
||||
});
|
||||
}
|
||||
match turn
|
||||
let status = turn
|
||||
.get("status")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.unwrap_or_default();
|
||||
if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject
|
||||
&& matches!(status, "completed" | "interrupted" | "failed")
|
||||
{
|
||||
append_direct_thread_event(
|
||||
&direct_thread_id,
|
||||
DirectThreadRawEventDraft {
|
||||
event_type: "turn.completed".to_string(),
|
||||
turn_id: turn_id.clone(),
|
||||
item_id: None,
|
||||
payload: serde_json::json!({ "status": status }),
|
||||
},
|
||||
);
|
||||
}
|
||||
match status {
|
||||
"completed" => {
|
||||
return final_text
|
||||
.filter(|text| !text.trim().is_empty())
|
||||
@@ -3739,7 +3867,9 @@ async fn read_game_creator_codex_app_server_stdout(
|
||||
| "item/completed"
|
||||
| "rawResponseItem/completed"
|
||||
| "turn/completed"
|
||||
) && safe_activity.is_none()
|
||||
) && direct_codex_request_event_type(method).is_none()
|
||||
&& direct_codex_resolution_event_type(method).is_none()
|
||||
&& safe_activity.is_none()
|
||||
&& intermediate_text.is_none()
|
||||
{
|
||||
continue;
|
||||
@@ -3776,7 +3906,9 @@ async fn read_game_creator_codex_app_server_stdout(
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let event = if let Some(activity) = safe_activity {
|
||||
let event = if let Some(event_type) = direct_codex_resolution_event_type(method) {
|
||||
CodexTurnEvent::Request { event_type, params }
|
||||
} else if let Some(activity) = safe_activity {
|
||||
// Preparing notifications may carry private plan/reasoning text;
|
||||
// expose only the safe activity category. Other categories may
|
||||
// retain their bounded, redacted intermediate text below.
|
||||
@@ -3825,6 +3957,13 @@ async fn read_game_creator_codex_app_server_stdout(
|
||||
.cloned()
|
||||
.unwrap_or(serde_json::Value::Null),
|
||||
),
|
||||
method if direct_codex_request_event_type(method).is_some() => {
|
||||
CodexTurnEvent::Request {
|
||||
event_type: direct_codex_request_event_type(method)
|
||||
.expect("request event type checked above"),
|
||||
params,
|
||||
}
|
||||
}
|
||||
_ => CodexTurnEvent::Terminal(params),
|
||||
}
|
||||
};
|
||||
@@ -4073,6 +4212,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at(
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -4090,56 +4230,11 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_observer(
|
||||
None,
|
||||
Some(observer),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
fn direct_codex_canonical_project_identity(
|
||||
root: &std::path::Path,
|
||||
) -> Result<(std::path::PathBuf, String), String> {
|
||||
let (canonical_root, _) = resolve_direct_codex_project_authority(root)?;
|
||||
let manifest = read_manifest(&canonical_root.join(".agent/manifest.json"))
|
||||
.map_err(|error| format!("读取 DirectProject 权威项目身份失败:{error}"))?;
|
||||
let manifest_project_id = manifest.project_id.trim();
|
||||
if manifest_project_id.is_empty() || manifest_project_id.chars().count() > 256 {
|
||||
return Err("DirectProject manifest.projectId 不满足身份边界".to_string());
|
||||
}
|
||||
let path_identity = direct_codex_os_path_identity_bytes(&canonical_root);
|
||||
Ok((
|
||||
canonical_root,
|
||||
direct_codex_project_identity_digest(&path_identity, manifest_project_id.as_bytes()),
|
||||
))
|
||||
}
|
||||
|
||||
fn direct_codex_os_path_identity_bytes(path: &std::path::Path) -> Vec<u8> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
return path.as_os_str().as_bytes().to_vec();
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
let mut bytes = Vec::new();
|
||||
for unit in path.as_os_str().encode_wide() {
|
||||
bytes.extend_from_slice(&unit.to_le_bytes());
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
path.as_os_str().to_string_lossy().as_bytes().to_vec()
|
||||
}
|
||||
|
||||
fn direct_codex_project_identity_digest(path_identity: &[u8], project_id: &[u8]) -> String {
|
||||
let mut digest = Sha256::new();
|
||||
digest.update(b"genarrative-direct-project-identity.v1\0");
|
||||
digest.update((path_identity.len() as u64).to_le_bytes());
|
||||
digest.update(path_identity);
|
||||
digest.update((project_id.len() as u64).to_le_bytes());
|
||||
digest.update(project_id);
|
||||
format!("{:x}", digest.finalize())
|
||||
}
|
||||
|
||||
pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer(
|
||||
root: &std::path::Path,
|
||||
system_prompt: String,
|
||||
@@ -4147,6 +4242,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer(
|
||||
client_turn_id: Option<&str>,
|
||||
observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>,
|
||||
audit: Option<&mut DirectCodexTurnAudit>,
|
||||
direct_user_item: Option<serde_json::Value>,
|
||||
) -> Result<String, String> {
|
||||
// Resolve project authority before deriving the pool/thread identity. A
|
||||
// caller may hold a stable symlink path whose target changes between
|
||||
@@ -4210,6 +4306,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer(
|
||||
request,
|
||||
Some(&codex_root),
|
||||
effective_client_turn_id,
|
||||
direct_user_item.as_ref(),
|
||||
None,
|
||||
observer,
|
||||
audit,
|
||||
@@ -4365,6 +4462,22 @@ mod tests {
|
||||
assert!(!key.to_string_lossy().starts_with("\\\\?\\"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_thread_item_projection_drops_full_app_server_payload() {
|
||||
let item = serde_json::json!({
|
||||
"id": "item-1",
|
||||
"type": "mcpToolCall",
|
||||
"tool": "agc_write_file",
|
||||
"arguments": { "path": "game/index.html", "token": "secret" },
|
||||
"result": { "content": "large output" }
|
||||
});
|
||||
assert_eq!(direct_thread_item_id(&item).as_deref(), Some("item-1"));
|
||||
assert_eq!(
|
||||
direct_thread_item_started_payload(&item),
|
||||
serde_json::json!({ "itemType": "mcpToolCall" })
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_item_activities_are_closed_safe_categories() {
|
||||
let allowed = [
|
||||
@@ -1,382 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
pub(crate) const MAX_DIRECT_CODEX_REFERENCES: usize = 32;
|
||||
const MAX_DIRECT_CODEX_REFERENCE_ID_CHARS: usize = 200;
|
||||
const MAX_DIRECT_CODEX_REFERENCE_LABEL_CHARS: usize = 160;
|
||||
const MAX_DIRECT_CODEX_REFERENCE_SOURCE_CHARS: usize = 32;
|
||||
const MAX_DIRECT_CODEX_REFERENCE_ELEMENT_CHARS: usize = 80;
|
||||
const MAX_DIRECT_CODEX_REFERENCE_TEXT_CHARS: usize = 240;
|
||||
|
||||
const DIRECT_CODEX_REFERENCE_HEADER: &str =
|
||||
"[本轮用户引用素材:以下均为当前项目已确认的安全引用。请使用稳定资源 ID 和项目相对路径读取,不要读取或输出其它路径。]";
|
||||
|
||||
#[derive(Clone, Debug, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase", tag = "type")]
|
||||
pub(crate) enum DirectCodexTurnReference {
|
||||
#[serde(rename = "resource")]
|
||||
Resource(DirectCodexResourceReference),
|
||||
#[serde(rename = "runtime-region")]
|
||||
RuntimeRegion(DirectCodexRuntimeRegionReference),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct DirectCodexResourceReference {
|
||||
pub(crate) resource_id: String,
|
||||
#[serde(default)]
|
||||
pub(crate) label: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(crate) source: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct DirectCodexRuntimeRegionReference {
|
||||
#[serde(default)]
|
||||
pub(crate) label: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(crate) run_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(crate) version_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(crate) element_tag: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(crate) element_role: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(crate) text: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(crate) width: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub(crate) height: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub(crate) resource_ids: Vec<String>,
|
||||
}
|
||||
|
||||
fn sanitize_reference_text(value: &str, max_chars: usize) -> Option<String> {
|
||||
let sanitized = value
|
||||
.trim()
|
||||
.chars()
|
||||
.filter(|character| !character.is_control())
|
||||
.take(max_chars)
|
||||
.collect::<String>();
|
||||
if sanitized.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(sanitized)
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_reference_label(value: Option<&str>) -> Option<String> {
|
||||
value.and_then(|value| sanitize_reference_text(value, MAX_DIRECT_CODEX_REFERENCE_LABEL_CHARS))
|
||||
}
|
||||
|
||||
fn sanitize_reference_source(value: Option<&str>) -> Option<String> {
|
||||
let value = sanitize_reference_text(value?, MAX_DIRECT_CODEX_REFERENCE_SOURCE_CHARS)?;
|
||||
if value
|
||||
.chars()
|
||||
.all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.'))
|
||||
{
|
||||
Some(value)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_reference_element(value: Option<&str>) -> Option<String> {
|
||||
let value = sanitize_reference_text(value?, MAX_DIRECT_CODEX_REFERENCE_ELEMENT_CHARS)?;
|
||||
if value
|
||||
.chars()
|
||||
.all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.'))
|
||||
{
|
||||
Some(value)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_reference_dimension(value: Option<f64>) -> Option<u32> {
|
||||
value
|
||||
.filter(|value| value.is_finite() && *value > 0.0)
|
||||
.map(|value| value.round().clamp(1.0, 100_000.0) as u32)
|
||||
}
|
||||
|
||||
fn asset_display_label(asset: &GameCreationAppAssetManifestEntry) -> String {
|
||||
let basename = asset
|
||||
.local_path
|
||||
.rsplit(['/', '\\'])
|
||||
.next()
|
||||
.unwrap_or(&asset.id);
|
||||
let without_extension = basename
|
||||
.rsplit_once('.')
|
||||
.map(|(name, _)| name)
|
||||
.unwrap_or(basename)
|
||||
.trim();
|
||||
if without_extension.is_empty() {
|
||||
asset.id.clone()
|
||||
} else {
|
||||
without_extension
|
||||
.chars()
|
||||
.filter(|character| !character.is_control())
|
||||
.take(MAX_DIRECT_CODEX_REFERENCE_LABEL_CHARS)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_resource_reference_id(value: &str) -> Result<String, String> {
|
||||
let resource_id = value.trim();
|
||||
if resource_id.is_empty()
|
||||
|| resource_id.chars().count() > MAX_DIRECT_CODEX_REFERENCE_ID_CHARS
|
||||
|| resource_id.chars().any(char::is_control)
|
||||
{
|
||||
return Err("引用的素材 ID 无效,请移除后重新选择".to_string());
|
||||
}
|
||||
Ok(resource_id.to_string())
|
||||
}
|
||||
|
||||
fn render_resource_reference_line(
|
||||
manifest: &GameCreationAppManifest,
|
||||
reference: &DirectCodexResourceReference,
|
||||
) -> Result<String, String> {
|
||||
let resource_id = validate_resource_reference_id(&reference.resource_id)?;
|
||||
let asset = manifest
|
||||
.assets
|
||||
.iter()
|
||||
.find(|asset| asset.id == resource_id)
|
||||
.ok_or_else(|| "引用的素材已不存在,请移除后重新选择".to_string())?;
|
||||
let local_path = sanitize_attachment_local_path(&asset.local_path)
|
||||
.ok_or_else(|| "引用的素材路径无效,请移除后重新选择".to_string())?;
|
||||
let label = sanitize_reference_label(reference.label.as_deref())
|
||||
.unwrap_or_else(|| asset_display_label(asset));
|
||||
let source = sanitize_reference_source(reference.source.as_deref())
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
Ok(format!(
|
||||
"- 素材 ID:{resource_id};名称:{label};类型:{};媒体类型:{};项目路径:{local_path};来源:{source}",
|
||||
asset.kind, asset.media_type
|
||||
))
|
||||
}
|
||||
|
||||
fn render_runtime_region_reference_line(
|
||||
manifest: &GameCreationAppManifest,
|
||||
reference: &DirectCodexRuntimeRegionReference,
|
||||
) -> Result<String, String> {
|
||||
let label = sanitize_reference_label(reference.label.as_deref())
|
||||
.unwrap_or_else(|| "运行画面区域".to_string());
|
||||
let run_id = sanitize_reference_source(reference.run_id.as_deref());
|
||||
let version_id = sanitize_reference_source(reference.version_id.as_deref());
|
||||
let element_tag = sanitize_reference_element(reference.element_tag.as_deref());
|
||||
let element_role = sanitize_reference_element(reference.element_role.as_deref());
|
||||
let text = reference
|
||||
.text
|
||||
.as_deref()
|
||||
.and_then(|value| sanitize_reference_text(value, MAX_DIRECT_CODEX_REFERENCE_TEXT_CHARS));
|
||||
let width = sanitize_reference_dimension(reference.width);
|
||||
let height = sanitize_reference_dimension(reference.height);
|
||||
|
||||
// `resourceIds` 是本模块唯一由客户端直接给出、且自身还是一条列表的字段:条数不设界时,
|
||||
// 每个 id 都要扫一遍 manifest(O(assets)),注入提示词的 `关联素材 ID:…` 行也会跟着无界
|
||||
// 变长(最终只被 32 MiB 写入护栏拦下,变成一条和原因无关的连接级错误)。这里按模块的
|
||||
// 失败关闭口径直接拒绝超限,而不是静默丢掉用户选中的关联。
|
||||
if reference.resource_ids.len() > MAX_DIRECT_CODEX_REFERENCES {
|
||||
return Err(format!(
|
||||
"运行画面区域一次最多关联 {MAX_DIRECT_CODEX_REFERENCES} 个素材,请重新点选"
|
||||
));
|
||||
}
|
||||
let mut related_resource_ids = Vec::new();
|
||||
for resource_id in &reference.resource_ids {
|
||||
let resource_id = validate_resource_reference_id(resource_id)?;
|
||||
if !manifest.assets.iter().any(|asset| asset.id == resource_id) {
|
||||
return Err("运行画面引用的素材已变化,请重新点选".to_string());
|
||||
}
|
||||
// 去重:同一个 id 在注入提示词里重复出现没有信息量,只是把行撑长。
|
||||
// 条数已按上限收口,所以这里的逐项比较不会退化成大面积二次扫描。
|
||||
if !related_resource_ids.contains(&resource_id) {
|
||||
related_resource_ids.push(resource_id);
|
||||
}
|
||||
}
|
||||
|
||||
let mut parts = vec![format!("名称:{label}")];
|
||||
if let Some(run_id) = run_id {
|
||||
parts.push(format!("运行标识:{run_id}"));
|
||||
}
|
||||
if let Some(version_id) = version_id {
|
||||
parts.push(format!("版本标识:{version_id}"));
|
||||
}
|
||||
if let Some(element_tag) = element_tag {
|
||||
parts.push(format!("元素:{element_tag}"));
|
||||
}
|
||||
if let Some(element_role) = element_role {
|
||||
parts.push(format!("角色:{element_role}"));
|
||||
}
|
||||
if let Some(text) = text {
|
||||
parts.push(format!("文本摘要:{text}"));
|
||||
}
|
||||
if let (Some(width), Some(height)) = (width, height) {
|
||||
parts.push(format!("尺寸:{width}x{height}"));
|
||||
}
|
||||
if !related_resource_ids.is_empty() {
|
||||
parts.push(format!("关联素材 ID:{}", related_resource_ids.join(",")));
|
||||
}
|
||||
Ok(format!("- 运行画面区域:{}", parts.join(";")))
|
||||
}
|
||||
|
||||
pub(crate) fn render_direct_codex_references_section(
|
||||
root: &Path,
|
||||
references: &[DirectCodexTurnReference],
|
||||
) -> Result<Option<String>, String> {
|
||||
if references.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
if references.len() > MAX_DIRECT_CODEX_REFERENCES {
|
||||
return Err(format!("一次最多引用 {MAX_DIRECT_CODEX_REFERENCES} 个素材"));
|
||||
}
|
||||
let manifest = read_manifest_for_project(root)?;
|
||||
let mut lines = Vec::with_capacity(references.len());
|
||||
for reference in references {
|
||||
lines.push(match reference {
|
||||
DirectCodexTurnReference::Resource(reference) => {
|
||||
render_resource_reference_line(&manifest, reference)?
|
||||
}
|
||||
DirectCodexTurnReference::RuntimeRegion(reference) => {
|
||||
render_runtime_region_reference_line(&manifest, reference)?
|
||||
}
|
||||
});
|
||||
}
|
||||
Ok(Some(
|
||||
std::iter::once(DIRECT_CODEX_REFERENCE_HEADER.to_string())
|
||||
.chain(lines)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn fixture_project() -> tempfile::TempDir {
|
||||
let directory = tempfile::tempdir().expect("temp project");
|
||||
let root = directory.path();
|
||||
std::fs::create_dir_all(root.join(".agent")).expect("create agent dir");
|
||||
let mut manifest = new_game_creation_app_manifest("project-1", "测试项目");
|
||||
manifest.assets.push(GameCreationAppAssetManifestEntry {
|
||||
id: "asset-hero".to_string(),
|
||||
kind: "character".to_string(),
|
||||
media_type: "image/png".to_string(),
|
||||
local_path: "assets/hero.png".to_string(),
|
||||
source: GameCreationAppAssetSource {
|
||||
kind: GameCreationAppAssetSourceKind::Uploaded,
|
||||
canvas_project_id: None,
|
||||
resource_id: None,
|
||||
asset_object_id: None,
|
||||
task_id: None,
|
||||
prompt: None,
|
||||
model: None,
|
||||
generation_route: None,
|
||||
generation_kind: None,
|
||||
reference_resource_ids: Vec::new(),
|
||||
},
|
||||
image_sequence_frames: None,
|
||||
image_sequence_duration_ms: None,
|
||||
category: game_creation_app_asset_category_for_kind("character"),
|
||||
tags: Vec::new(),
|
||||
});
|
||||
write_manifest(&root.join(".agent/manifest.json"), &manifest).expect("write manifest");
|
||||
directory
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resource_reference_uses_manifest_identity_and_never_accepts_client_paths() {
|
||||
let project = fixture_project();
|
||||
let reference: DirectCodexTurnReference = serde_json::from_str(
|
||||
r#"{"type":"resource","resourceId":"asset-hero","label":"主角","source":"asset-picker","localPath":"C:\\secret.png"}"#,
|
||||
)
|
||||
.expect("reference json");
|
||||
let section = render_direct_codex_references_section(
|
||||
project.path(),
|
||||
std::slice::from_ref(&reference),
|
||||
)
|
||||
.expect("render")
|
||||
.expect("section");
|
||||
assert!(section.contains("素材 ID:asset-hero"));
|
||||
assert!(section.contains("名称:主角"));
|
||||
assert!(section.contains("项目路径:assets/hero.png"));
|
||||
assert!(!section.contains("C:\\secret.png"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleted_resource_fails_closed() {
|
||||
let project = fixture_project();
|
||||
let reference: DirectCodexTurnReference = serde_json::from_str(
|
||||
r#"{"type":"resource","resourceId":"asset-missing","label":"不存在"}"#,
|
||||
)
|
||||
.expect("reference json");
|
||||
let error = render_direct_codex_references_section(
|
||||
project.path(),
|
||||
std::slice::from_ref(&reference),
|
||||
)
|
||||
.expect_err("missing resource");
|
||||
assert_eq!(error, "引用的素材已不存在,请移除后重新选择");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_region_keeps_only_safe_summary_and_existing_resource_ids() {
|
||||
let project = fixture_project();
|
||||
let reference: DirectCodexTurnReference = serde_json::from_str(
|
||||
r#"{"type":"runtime-region","label":"开始按钮","runId":"run-1","elementTag":"button","elementRole":"button","text":"开始游戏","width":120.4,"height":40.2,"resourceIds":["asset-hero"],"html":"<button onclick=secret>"}"#,
|
||||
)
|
||||
.expect("reference json");
|
||||
let section = render_direct_codex_references_section(
|
||||
project.path(),
|
||||
std::slice::from_ref(&reference),
|
||||
)
|
||||
.expect("render")
|
||||
.expect("section");
|
||||
assert!(section.contains("运行画面区域:名称:开始按钮"));
|
||||
assert!(section.contains("文本摘要:开始游戏"));
|
||||
assert!(section.contains("尺寸:120x40"));
|
||||
assert!(section.contains("关联素材 ID:asset-hero"));
|
||||
assert!(!section.contains("onclick"));
|
||||
assert!(!section.contains("secret"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_region_dedupes_and_bounds_related_resource_ids() {
|
||||
let project = fixture_project();
|
||||
// 同一个 id 重复出现只应产生一条关联。
|
||||
let duplicated: DirectCodexTurnReference = serde_json::from_str(
|
||||
r#"{"type":"runtime-region","label":"开始按钮","resourceIds":["asset-hero","asset-hero"," asset-hero "],"text":"开始游戏"}"#,
|
||||
)
|
||||
.expect("reference json");
|
||||
let section = render_direct_codex_references_section(
|
||||
project.path(),
|
||||
std::slice::from_ref(&duplicated),
|
||||
)
|
||||
.expect("render")
|
||||
.expect("section");
|
||||
assert!(
|
||||
section.contains("关联素材 ID:asset-hero\n")
|
||||
|| section.trim_end().ends_with("关联素材 ID:asset-hero"),
|
||||
"{section}"
|
||||
);
|
||||
assert!(
|
||||
!section.contains("asset-hero,"),
|
||||
"重复 id 不得在注入提示词里重复出现:{section}"
|
||||
);
|
||||
|
||||
// 超出上限直接失败关闭:不能按对方给的长度注入提示词。
|
||||
let oversized_ids = (0..MAX_DIRECT_CODEX_REFERENCES + 1)
|
||||
.map(|_| "\"asset-hero\"".to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
let oversized: DirectCodexTurnReference = serde_json::from_str(&format!(
|
||||
r#"{{"type":"runtime-region","label":"开始按钮","resourceIds":[{oversized_ids}]}}"#
|
||||
))
|
||||
.expect("reference json");
|
||||
let error = render_direct_codex_references_section(
|
||||
project.path(),
|
||||
std::slice::from_ref(&oversized),
|
||||
)
|
||||
.expect_err("oversized resource id list must fail closed");
|
||||
assert!(error.contains("最多关联"), "{error}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//! DirectProject user input 的 canonical Response item 深模块。
|
||||
|
||||
mod model;
|
||||
mod validation;
|
||||
mod wire;
|
||||
|
||||
pub(crate) use model::{
|
||||
DirectCodexUserContentPart, DirectCodexUserItem, DirectCodexUserMessageEnvelope,
|
||||
DirectCodexUserMessageItem, DirectCodexUserRole, DirectCodexUserRuntimeRegionPart,
|
||||
};
|
||||
pub(crate) use validation::validate_direct_codex_user_item;
|
||||
pub(crate) use wire::{
|
||||
direct_codex_user_item_to_prompt, direct_codex_user_item_to_response_item,
|
||||
direct_codex_user_item_to_wire_input,
|
||||
};
|
||||
@@ -0,0 +1,125 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
|
||||
/// DirectProject 本轮 user input 的唯一结构化入口。
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
|
||||
#[serde(tag = "type", deny_unknown_fields)]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))]
|
||||
pub(crate) enum DirectCodexUserItem {
|
||||
#[serde(rename = "message")]
|
||||
Message(DirectCodexUserMessageItem),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))]
|
||||
pub(crate) struct DirectCodexUserMessageItem {
|
||||
pub(crate) role: DirectCodexUserRole,
|
||||
pub(crate) content: Vec<DirectCodexUserContentPart>,
|
||||
pub(crate) id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))]
|
||||
pub(crate) enum DirectCodexUserRole {
|
||||
User,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
|
||||
#[serde(tag = "type", rename_all_fields = "camelCase", deny_unknown_fields)]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))]
|
||||
pub(crate) enum DirectCodexUserContentPart {
|
||||
#[serde(rename = "input_text")]
|
||||
InputText { text: String },
|
||||
#[serde(rename = "agc_resource_reference")]
|
||||
AgcResourceReference { resource_id: String },
|
||||
#[serde(rename = "agc_runtime_region_reference")]
|
||||
AgcRuntimeRegionReference(DirectCodexUserRuntimeRegionPart),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))]
|
||||
pub(crate) struct DirectCodexUserRuntimeRegionPart {
|
||||
pub(crate) label: String,
|
||||
#[serde(default)]
|
||||
pub(crate) run_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(crate) version_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(crate) element_tag: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(crate) element_role: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(crate) text: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(crate) width: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub(crate) height: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub(crate) resource_ids: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))]
|
||||
pub(crate) struct DirectCodexUserMessageEnvelope {
|
||||
pub(crate) item: DirectCodexUserItem,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn resource_reference_serializes_with_only_camel_case_resource_id() {
|
||||
let item = DirectCodexUserItem::Message(DirectCodexUserMessageItem {
|
||||
role: DirectCodexUserRole::User,
|
||||
content: vec![DirectCodexUserContentPart::AgcResourceReference {
|
||||
resource_id: "asset-hero".to_string(),
|
||||
}],
|
||||
id: "turn-1".to_string(),
|
||||
});
|
||||
assert_eq!(
|
||||
serde_json::to_value(item).expect("serialize user item"),
|
||||
json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{
|
||||
"type": "agc_resource_reference",
|
||||
"resourceId": "asset-hero"
|
||||
}],
|
||||
"id": "turn-1"
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resource_reference_rejects_extra_identity_fields() {
|
||||
let error = serde_json::from_value::<DirectCodexUserItem>(json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{
|
||||
"type": "agc_resource_reference",
|
||||
"resourceId": "asset-hero",
|
||||
"label": "主角"
|
||||
}],
|
||||
"id": "turn-1"
|
||||
}))
|
||||
.expect_err("label must not be accepted on resource reference");
|
||||
assert!(error.to_string().contains("unknown field"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_content_part_fails_closed() {
|
||||
serde_json::from_value::<DirectCodexUserItem>(json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{"type": "future_part", "value": "x"}],
|
||||
"id": "turn-1"
|
||||
}))
|
||||
.expect_err("unknown content part must fail closed");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
use super::model::{
|
||||
DirectCodexUserContentPart, DirectCodexUserItem, DirectCodexUserRole,
|
||||
DirectCodexUserRuntimeRegionPart,
|
||||
};
|
||||
use crate::agent::{
|
||||
read_manifest_for_project, sanitize_attachment_local_path, GameCreationAppManifest,
|
||||
};
|
||||
use std::path::Path;
|
||||
|
||||
pub(crate) const MAX_DIRECT_CODEX_REFERENCES: usize = 32;
|
||||
|
||||
pub(crate) fn validate_direct_codex_user_item(
|
||||
root: &Path,
|
||||
item: &DirectCodexUserItem,
|
||||
) -> Result<(), String> {
|
||||
let DirectCodexUserItem::Message(message) = item;
|
||||
if !matches!(message.role, DirectCodexUserRole::User) {
|
||||
return Err("DirectProject 只接受 user message item".to_string());
|
||||
}
|
||||
if message.id.trim().is_empty() {
|
||||
return Err("DirectProject user item 缺少稳定 id".to_string());
|
||||
}
|
||||
if message.content.is_empty() {
|
||||
return Err("DirectProject user item content 不能为空".to_string());
|
||||
}
|
||||
let manifest = read_manifest_for_project(root)?;
|
||||
let mut reference_count = 0usize;
|
||||
for part in &message.content {
|
||||
match part {
|
||||
DirectCodexUserContentPart::InputText { text } => {
|
||||
if text.trim().is_empty() {
|
||||
return Err("DirectProject input_text 不能为空".to_string());
|
||||
}
|
||||
}
|
||||
DirectCodexUserContentPart::AgcResourceReference { resource_id } => {
|
||||
reference_count = reference_count.saturating_add(1);
|
||||
validate_resource_id_and_manifest(&manifest, resource_id)?;
|
||||
}
|
||||
DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => {
|
||||
reference_count = reference_count.saturating_add(1);
|
||||
validate_runtime_region_reference(&manifest, reference)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
if reference_count > MAX_DIRECT_CODEX_REFERENCES {
|
||||
return Err(format!("一次最多引用 {MAX_DIRECT_CODEX_REFERENCES} 个素材"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn validate_resource_id_and_manifest(
|
||||
manifest: &GameCreationAppManifest,
|
||||
resource_id: &str,
|
||||
) -> Result<(), String> {
|
||||
let resource_id = resource_id.trim();
|
||||
if resource_id.is_empty()
|
||||
|| resource_id.chars().count() > 200
|
||||
|| resource_id.chars().any(char::is_control)
|
||||
{
|
||||
return Err("引用的素材 ID 无效,请移除后重新选择".to_string());
|
||||
}
|
||||
let asset = manifest
|
||||
.assets
|
||||
.iter()
|
||||
.find(|asset| asset.id == resource_id)
|
||||
.ok_or_else(|| "引用的素材已不存在,请移除后重新选择".to_string())?;
|
||||
sanitize_attachment_local_path(&asset.local_path)
|
||||
.ok_or_else(|| "引用的素材路径无效,请移除后重新选择".to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_runtime_region_reference(
|
||||
manifest: &GameCreationAppManifest,
|
||||
reference: &DirectCodexUserRuntimeRegionPart,
|
||||
) -> Result<(), String> {
|
||||
if reference.label.trim().is_empty() {
|
||||
return Err("运行画面区域缺少名称".to_string());
|
||||
}
|
||||
if reference.resource_ids.len() > MAX_DIRECT_CODEX_REFERENCES {
|
||||
return Err(format!(
|
||||
"运行画面区域一次最多关联 {MAX_DIRECT_CODEX_REFERENCES} 个素材"
|
||||
));
|
||||
}
|
||||
for resource_id in &reference.resource_ids {
|
||||
validate_resource_id_and_manifest(manifest, resource_id)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
use super::model::{DirectCodexUserContentPart, DirectCodexUserItem};
|
||||
use super::validation::validate_direct_codex_user_item;
|
||||
use crate::agent::{read_manifest_for_project, sanitize_attachment_local_path};
|
||||
use serde_json::Value;
|
||||
use std::path::Path;
|
||||
|
||||
/// 将历史中的 canonical user item 投影为 Codex `response_item` message。
|
||||
/// 非 user message 的标准 Response item 原样返回;未知形状直接失败。
|
||||
pub(crate) fn direct_codex_user_item_to_response_item(
|
||||
root: &Path,
|
||||
item: &Value,
|
||||
) -> Result<Value, String> {
|
||||
let is_user_message = item.get("type").and_then(Value::as_str) == Some("message")
|
||||
&& item.get("role").and_then(Value::as_str) == Some("user");
|
||||
if !is_user_message {
|
||||
if item.get("type").and_then(Value::as_str).is_some() {
|
||||
return Ok(item.clone());
|
||||
}
|
||||
return Err("DirectProject 历史 item 缺少 type,无法投影为 Codex item".to_string());
|
||||
}
|
||||
let canonical: DirectCodexUserItem = serde_json::from_value(item.clone())
|
||||
.map_err(|error| format!("DirectProject user item 无法转换为 Codex item:{error}"))?;
|
||||
let content = direct_codex_user_item_to_response_content(root, &canonical)?;
|
||||
let mut projected = serde_json::json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": content,
|
||||
});
|
||||
if let Some(id) = item.get("id").and_then(Value::as_str) {
|
||||
projected["id"] = Value::String(id.to_string());
|
||||
}
|
||||
Ok(projected)
|
||||
}
|
||||
|
||||
fn direct_codex_user_item_to_response_content(
|
||||
root: &Path,
|
||||
item: &DirectCodexUserItem,
|
||||
) -> Result<Vec<Value>, String> {
|
||||
let Value::Array(input) = direct_codex_user_item_to_wire_input(root, item)? else {
|
||||
return Err("DirectProject user item wire content 不是数组".to_string());
|
||||
};
|
||||
input
|
||||
.into_iter()
|
||||
.map(|part| {
|
||||
let text = part
|
||||
.get("text")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| "DirectProject user item wire part 缺少 text".to_string())?;
|
||||
Ok(serde_json::json!({ "type": "input_text", "text": text }))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 将 canonical user item 转为 app-server `turn/start.input` 可接受的文本数组。
|
||||
/// AGC 私有 part 只在这里投影为安全摘要,canonical item 本身不被修改。
|
||||
pub(crate) fn direct_codex_user_item_to_wire_input(
|
||||
root: &Path,
|
||||
item: &DirectCodexUserItem,
|
||||
) -> Result<Value, String> {
|
||||
validate_direct_codex_user_item(root, item)?;
|
||||
let manifest = read_manifest_for_project(root)?;
|
||||
let DirectCodexUserItem::Message(message) = item;
|
||||
let mut input = Vec::with_capacity(message.content.len());
|
||||
for part in &message.content {
|
||||
let text = match part {
|
||||
DirectCodexUserContentPart::InputText { text } => text.clone(),
|
||||
DirectCodexUserContentPart::AgcResourceReference { resource_id } => {
|
||||
let asset = manifest
|
||||
.assets
|
||||
.iter()
|
||||
.find(|asset| asset.id == resource_id.trim())
|
||||
.ok_or_else(|| "引用的素材已不存在,请移除后重新选择".to_string())?;
|
||||
let path = sanitize_attachment_local_path(&asset.local_path)
|
||||
.ok_or_else(|| "引用的素材路径无效,请移除后重新选择".to_string())?;
|
||||
format!(
|
||||
"[素材引用 resourceId={};项目路径={path}]",
|
||||
resource_id.trim()
|
||||
)
|
||||
}
|
||||
DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => {
|
||||
let resources = reference
|
||||
.resource_ids
|
||||
.iter()
|
||||
.map(|id| id.trim())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
let mut summary = format!("[运行画面区域:名称={} ", reference.label.trim());
|
||||
if let Some(run_id) = reference.run_id.as_deref() {
|
||||
summary.push_str(&format!("运行标识={} ", run_id.trim()));
|
||||
}
|
||||
if let Some(role) = reference.element_role.as_deref() {
|
||||
summary.push_str(&format!("角色={} ", role.trim()));
|
||||
}
|
||||
if let Some(text) = reference.text.as_deref() {
|
||||
summary.push_str(&format!("文本={} ", text.trim()));
|
||||
}
|
||||
if !resources.is_empty() {
|
||||
summary.push_str(&format!("关联素材={resources}"));
|
||||
}
|
||||
summary.push(']');
|
||||
summary
|
||||
}
|
||||
};
|
||||
input.push(serde_json::json!({ "type": "text", "text": text }));
|
||||
}
|
||||
Ok(Value::Array(input))
|
||||
}
|
||||
|
||||
pub(crate) fn direct_codex_user_item_to_prompt(
|
||||
root: &Path,
|
||||
item: &DirectCodexUserItem,
|
||||
) -> Result<String, String> {
|
||||
let wire = direct_codex_user_item_to_wire_input(root, item)?;
|
||||
wire.as_array()
|
||||
.ok_or_else(|| "DirectProject user item wire input 不是数组".to_string())
|
||||
.map(|parts| {
|
||||
parts
|
||||
.iter()
|
||||
.filter_map(|part| part.get("text").and_then(Value::as_str))
|
||||
.collect::<String>()
|
||||
})
|
||||
.and_then(|prompt| {
|
||||
if prompt.trim().is_empty() {
|
||||
Err("DirectProject user item 不能转换为空 prompt".to_string())
|
||||
} else {
|
||||
Ok(prompt)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::direct_codex_user_item_to_response_item;
|
||||
use serde_json::json;
|
||||
use std::path::Path;
|
||||
|
||||
#[test]
|
||||
fn standard_response_item_passes_through_without_agc_private_parts() {
|
||||
let item = json!({
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "output_text", "text": "完成"}]
|
||||
});
|
||||
assert_eq!(
|
||||
direct_codex_user_item_to_response_item(Path::new("/unused"), &item)
|
||||
.expect("assistant response item should pass through"),
|
||||
item
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_item_projection_uses_input_text_not_turn_input_text() {
|
||||
let root = tempfile::tempdir().expect("temp project");
|
||||
crate::init_local_game_project_at(root.path(), "wire-test", "wire 投影测试")
|
||||
.expect("init project");
|
||||
let item = json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"id": "turn-1:user",
|
||||
"content": [{"type": "input_text", "text": "你好"}]
|
||||
});
|
||||
let projected = direct_codex_user_item_to_response_item(root.path(), &item)
|
||||
.expect("user response item should project");
|
||||
assert_eq!(projected["content"][0]["type"], "input_text");
|
||||
assert_ne!(projected["content"][0]["type"], "text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_item_without_type_fails_closed() {
|
||||
let error = direct_codex_user_item_to_response_item(
|
||||
Path::new("/unused"),
|
||||
&json!({"role": "assistant"}),
|
||||
)
|
||||
.expect_err("history item without type must fail");
|
||||
assert!(error.contains("缺少 type"), "{error}");
|
||||
}
|
||||
}
|
||||
@@ -17,9 +17,6 @@ const DIRECT_PROJECT_HISTORY_SCAN_PROBE_FINISHED: usize = 1;
|
||||
/// 写入侧与读取侧共用同一个信封类型:`project.jsonl` 由项目主对话与 DirectProject 共享,
|
||||
/// 这个值一旦只在写入侧改动,读取侧就会把对方的行当成坏行,整份历史立刻读不出来。
|
||||
pub(crate) const DIRECT_PROJECT_HISTORY_RECORD_TYPE: &str = "response_item";
|
||||
/// 格式切换到 `response_item`(#282)之前,DirectProject 主对话通过通用对话写入器
|
||||
/// 落到同一份 `project.jsonl`,行形状是 `PersistedLocalConversationMessageRecord`。
|
||||
const DIRECT_PROJECT_HISTORY_LEGACY_SCHEMA_VERSION: &str = "game-creator-conversation.v1";
|
||||
const DIRECT_PROJECT_INTERNAL_CONTEXT_KINDS: &[&str] = &[
|
||||
"host_skills.instructions",
|
||||
"permissions.instructions",
|
||||
@@ -180,79 +177,22 @@ fn direct_project_history_item_from_line(
|
||||
Ok(Some(item))
|
||||
}
|
||||
|
||||
/// 只接受两种行信封:`response_item`,以及白名单化的 legacy 行。返回 `None` 表示行已
|
||||
/// 被识别、但不该进入 Codex 上下文(legacy 的 `tool` 行)。
|
||||
/// 其余任何形状(含换了 `schemaVersion`、带 `type` 却不是 `response_item`、role 不在
|
||||
/// legacy 写入器自己的角色集合内、content 不是非空字符串)都判定为损坏并失败关闭。
|
||||
/// 只接受 `response_item` 行信封;其它历史格式不提供迁移或 fallback,直接失败关闭。
|
||||
fn direct_project_history_item_from_parsed_line(
|
||||
path: &Path,
|
||||
parsed: &Value,
|
||||
) -> Result<Option<Value>, String> {
|
||||
if parsed.get("type").and_then(Value::as_str) == Some(DIRECT_PROJECT_HISTORY_RECORD_TYPE) {
|
||||
return parsed
|
||||
.get("payload")
|
||||
.cloned()
|
||||
.map(Some)
|
||||
.ok_or_else(|| format!("DirectProject 历史记录缺少 payload:{}", path.display()));
|
||||
if parsed.get("type").and_then(Value::as_str) != Some(DIRECT_PROJECT_HISTORY_RECORD_TYPE) {
|
||||
return Err(format!(
|
||||
"DirectProject 历史记录类型无效:{}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
direct_project_legacy_row(parsed)
|
||||
.map(|row| row.message_item())
|
||||
.ok_or_else(|| format!("DirectProject 历史记录类型无效:{}", path.display()))
|
||||
}
|
||||
|
||||
/// 白名单化 legacy 行的投影结果。
|
||||
enum DirectProjectLegacyRow {
|
||||
/// user/assistant 行:投影成 message item 进入历史。
|
||||
Message(Value),
|
||||
/// 已识别但不可注入的行(`tool`):与 developer/system item 同样处理,不进 Codex
|
||||
/// 上下文。legacy 行不是 Responses item,`tool` 行无法还原成真正的工具 item,
|
||||
/// 注入会造出假的工具消息;聊天投影本来也只展示 user/assistant。
|
||||
NotChat,
|
||||
}
|
||||
|
||||
impl DirectProjectLegacyRow {
|
||||
fn message_item(self) -> Option<Value> {
|
||||
match self {
|
||||
DirectProjectLegacyRow::Message(item) => Some(item),
|
||||
DirectProjectLegacyRow::NotChat => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 格式切换前的 legacy 行投影。存量用户项目的历史文件全是这种行,读取时投影成与
|
||||
/// `direct_project_local_message_item` 同形状的 message item,`role` 与 `content`
|
||||
/// 原样保留(不 trim、不改写、不合并),未知字段忽略。
|
||||
///
|
||||
/// 角色白名单取的是 legacy 写入器自己的角色集合,也就是
|
||||
/// `project/conversation.rs` 里 `matches!(role, "user" | "assistant" | "tool")` 这一
|
||||
/// 条校验,所以「legacy 写入器能写出的行」被完整覆盖,不会有第三种角色漏进来;
|
||||
/// 白名单之外的角色(手改文件、未来写入器)仍按损坏失败关闭。
|
||||
fn direct_project_legacy_row(parsed: &Value) -> Option<DirectProjectLegacyRow> {
|
||||
let object = parsed.as_object()?;
|
||||
if object.contains_key("type")
|
||||
|| object.get("schemaVersion").and_then(Value::as_str)
|
||||
!= Some(DIRECT_PROJECT_HISTORY_LEGACY_SCHEMA_VERSION)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let role = object.get("role").and_then(Value::as_str)?;
|
||||
if !matches!(role, "user" | "assistant" | "tool") {
|
||||
return None;
|
||||
}
|
||||
let content = object.get("content").and_then(Value::as_str)?;
|
||||
if content.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if role == "tool" {
|
||||
return Some(DirectProjectLegacyRow::NotChat);
|
||||
}
|
||||
let message_id = object
|
||||
.get("messageId")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|message_id| !message_id.is_empty());
|
||||
Some(DirectProjectLegacyRow::Message(
|
||||
direct_project_message_item(role, content, message_id),
|
||||
))
|
||||
parsed
|
||||
.get("payload")
|
||||
.cloned()
|
||||
.map(Some)
|
||||
.ok_or_else(|| format!("DirectProject 历史记录缺少 payload:{}", path.display()))
|
||||
}
|
||||
|
||||
/// 测试专用探针:在"锁外幂等回扫"这一段的两端回调。
|
||||
@@ -512,8 +452,8 @@ pub(crate) fn direct_project_local_message_item(
|
||||
))
|
||||
}
|
||||
|
||||
/// 本地补写与 legacy 投影共用同一种 Responses message item 形状,两者的区别只在
|
||||
/// 是否对入参做 trim 校验:本地补写走上面的校验,legacy 行按文件内容逐字节投影。
|
||||
/// 本地补写与 Codex response item 共用同一种 Responses message item 形状;本地补写额外做
|
||||
/// trim 校验,历史读取只接受 response_item envelope。
|
||||
fn direct_project_message_item(role: &str, content: &str, message_id: Option<&str>) -> Value {
|
||||
let mut item = serde_json::json!({
|
||||
"type": "message",
|
||||
@@ -572,6 +512,36 @@ pub(crate) fn read_direct_project_history_items_at(root: &Path) -> Result<Vec<Va
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
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)?;
|
||||
let end = match before_item_id {
|
||||
Some(item_id) => items
|
||||
.iter()
|
||||
.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))
|
||||
}
|
||||
|
||||
pub(crate) fn read_direct_project_last_item_id_at(root: &Path) -> Result<Option<String>, String> {
|
||||
Ok(read_direct_project_history_items_at(root)?
|
||||
.into_iter()
|
||||
.rev()
|
||||
.find_map(|item| {
|
||||
item.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|id| !id.is_empty())
|
||||
.map(str::to_string)
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn read_direct_project_chat_history_at(
|
||||
root: &Path,
|
||||
) -> Result<LocalConversationResult, String> {
|
||||
@@ -626,7 +596,7 @@ mod tests {
|
||||
|
||||
fn init_history_project(name: &str) -> tempfile::TempDir {
|
||||
let root = tempfile::tempdir().expect("temp project");
|
||||
crate::init_local_game_project_at(root.path(), name, "历史格式兼容测试")
|
||||
crate::init_local_game_project_at(root.path(), name, "Response item 历史测试")
|
||||
.expect("init project");
|
||||
root
|
||||
}
|
||||
@@ -637,9 +607,8 @@ mod tests {
|
||||
std::fs::write(&path, format!("{}\n", lines.join("\n"))).expect("write history fixture");
|
||||
}
|
||||
|
||||
const LEGACY_USER_ROW: &str = r#"{"schemaVersion":"game-creator-conversation.v1","role":"user","content":"请创建菜单","agentId":null,"messageId":"direct-codex:turn-0001:user","updatedAt":1757000000}"#;
|
||||
const LEGACY_ASSISTANT_ROW: &str = r#"{"schemaVersion":"game-creator-conversation.v1","role":"assistant","content":"已完成 第一行\n第二行 ","agentId":null,"updatedAt":1757000001}"#;
|
||||
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":"已完成"}]}}"#;
|
||||
|
||||
/// 判据:争用类失败会被"有界退避重试"真的吃掉,最终把条目落一行。
|
||||
///
|
||||
@@ -648,7 +617,7 @@ mod tests {
|
||||
#[test]
|
||||
fn contention_failure_is_retried_with_bounded_backoff() {
|
||||
let root = init_history_project("contention-retry");
|
||||
write_history_lines(root.path(), &[LEGACY_USER_ROW]);
|
||||
write_history_lines(root.path(), &[RESPONSE_ITEM_ROW]);
|
||||
let marker = root
|
||||
.path()
|
||||
.join(".agent/runtime/test-fail-next-direct-project-history-append");
|
||||
@@ -680,7 +649,7 @@ mod tests {
|
||||
#[test]
|
||||
fn contention_failure_beyond_the_backoff_budget_fails_closed() {
|
||||
let root = init_history_project("contention-bounded");
|
||||
write_history_lines(root.path(), &[LEGACY_USER_ROW]);
|
||||
write_history_lines(root.path(), &[RESPONSE_ITEM_ROW]);
|
||||
let marker = root
|
||||
.path()
|
||||
.join(".agent/runtime/test-fail-next-direct-project-history-append");
|
||||
@@ -723,7 +692,7 @@ mod tests {
|
||||
write_history_lines(
|
||||
root.path(),
|
||||
&[
|
||||
LEGACY_USER_ROW,
|
||||
RESPONSE_ITEM_ROW,
|
||||
r#"{"schemaVersion":"game-creator"#,
|
||||
RESPONSE_ITEM_ROW,
|
||||
],
|
||||
@@ -761,7 +730,7 @@ mod tests {
|
||||
let root = init_history_project("scan-outside-lock");
|
||||
write_history_lines(
|
||||
root.path(),
|
||||
&[LEGACY_USER_ROW, RESPONSE_ITEM_ROW, LEGACY_ASSISTANT_ROW],
|
||||
&[RESPONSE_ITEM_ROW, RESPONSE_ITEM_ROW, RESPONSE_ASSISTANT_ROW],
|
||||
);
|
||||
let path = history_path(root.path());
|
||||
let fired = Arc::new(AtomicBool::new(false));
|
||||
@@ -805,7 +774,7 @@ mod tests {
|
||||
#[test]
|
||||
fn concurrent_append_after_the_out_of_lock_scan_still_prevents_a_duplicate_row() {
|
||||
let root = init_history_project("scan-stale-state");
|
||||
write_history_lines(root.path(), &[LEGACY_USER_ROW]);
|
||||
write_history_lines(root.path(), &[RESPONSE_ITEM_ROW]);
|
||||
let path = history_path(root.path());
|
||||
let item = json!({
|
||||
"type": "message",
|
||||
@@ -845,7 +814,7 @@ mod tests {
|
||||
#[test]
|
||||
fn conflicting_item_id_still_fails_closed_with_the_scan_outside_the_lock() {
|
||||
let root = init_history_project("id-conflict-outside-lock");
|
||||
write_history_lines(root.path(), &[LEGACY_USER_ROW]);
|
||||
write_history_lines(root.path(), &[RESPONSE_ITEM_ROW]);
|
||||
append_direct_project_history_item_at(
|
||||
root.path(),
|
||||
&json!({
|
||||
@@ -1033,97 +1002,13 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_conversation_rows_project_into_responses_message_items() {
|
||||
let root = init_history_project("legacy-projection");
|
||||
write_history_lines(root.path(), &[LEGACY_USER_ROW, LEGACY_ASSISTANT_ROW]);
|
||||
|
||||
let items = read_direct_project_history_items_at(root.path()).expect("read legacy history");
|
||||
assert_eq!(
|
||||
items,
|
||||
vec![
|
||||
json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"id": "direct-codex:turn-0001:user",
|
||||
"content": [{"type": "input_text", "text": "请创建菜单"}],
|
||||
}),
|
||||
json!({
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "output_text", "text": "已完成 第一行\n第二行 "}],
|
||||
}),
|
||||
]
|
||||
);
|
||||
|
||||
let chat = read_direct_project_chat_history_at(root.path()).expect("read legacy chat");
|
||||
assert_eq!(chat.messages.len(), 2);
|
||||
assert_eq!(chat.messages[0].role, "user");
|
||||
assert_eq!(chat.messages[0].content, "请创建菜单");
|
||||
assert_eq!(
|
||||
chat.messages[0].message_id.as_deref(),
|
||||
Some("direct-codex:turn-0001:user")
|
||||
);
|
||||
assert_eq!(chat.messages[1].role, "assistant");
|
||||
assert_eq!(chat.messages[1].content, "已完成 第一行\n第二行 ");
|
||||
assert_eq!(chat.messages[1].message_id, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_legacy_and_response_item_history_reads_in_file_order() {
|
||||
let root = init_history_project("mixed-history");
|
||||
write_history_lines(
|
||||
root.path(),
|
||||
&[LEGACY_USER_ROW, RESPONSE_ITEM_ROW, LEGACY_ASSISTANT_ROW],
|
||||
);
|
||||
|
||||
let items = read_direct_project_history_items_at(root.path()).expect("read mixed history");
|
||||
assert_eq!(
|
||||
items
|
||||
.iter()
|
||||
.map(|item| item.get("id").and_then(Value::as_str))
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
Some("direct-codex:turn-0001:user"),
|
||||
Some("codex-item-2"),
|
||||
None
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
items[1],
|
||||
json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"id": "codex-item-2",
|
||||
"content": [{"type": "input_text", "text": "再加一个按钮"}],
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
items[2]["content"][0]["text"],
|
||||
json!("已完成 第一行\n第二行 ")
|
||||
);
|
||||
|
||||
let chat = read_direct_project_chat_history_at(root.path()).expect("read mixed chat");
|
||||
assert_eq!(
|
||||
chat.messages
|
||||
.iter()
|
||||
.map(|message| (message.role.as_str(), message.content.as_str()))
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
("user", "请创建菜单"),
|
||||
("user", "再加一个按钮"),
|
||||
("assistant", "已完成 第一行\n第二行 "),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_compat_keeps_unrecognized_rows_failing_closed() {
|
||||
fn non_response_item_history_fails_closed() {
|
||||
let unrecognized_rows = [
|
||||
// 带 type 却不是 response_item
|
||||
r#"{"type":"message","role":"user","content":[{"type":"input_text","text":"x"}]}"#,
|
||||
// schemaVersion 不在白名单里
|
||||
// 旧 schema 不提供 fallback
|
||||
r#"{"schemaVersion":"game-creator-conversation.v2","role":"user","content":"x","agentId":null,"updatedAt":1}"#,
|
||||
// legacy 写入器角色集合之外的角色
|
||||
// 旧 schema 的其它角色也拒绝
|
||||
r#"{"schemaVersion":"game-creator-conversation.v1","role":"system","content":"x","agentId":null,"updatedAt":1}"#,
|
||||
r#"{"schemaVersion":"game-creator-conversation.v1","role":"developer","content":"x","agentId":null,"updatedAt":1}"#,
|
||||
// content 不是字符串
|
||||
@@ -1159,61 +1044,4 @@ mod tests {
|
||||
.expect_err("broken json must fail closed");
|
||||
assert!(error.starts_with("解析 DirectProject 历史失败"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_tool_row_is_recognized_but_stays_out_of_the_codex_context() {
|
||||
let root = init_history_project("legacy-tool-row");
|
||||
write_history_lines(
|
||||
root.path(),
|
||||
&[
|
||||
LEGACY_USER_ROW,
|
||||
r#"{"schemaVersion":"game-creator-conversation.v1","role":"tool","content":"{\"ok\":true}","agentId":null,"updatedAt":1757000002}"#,
|
||||
LEGACY_ASSISTANT_ROW,
|
||||
],
|
||||
);
|
||||
|
||||
let items = read_direct_project_history_items_at(root.path()).expect("read history");
|
||||
assert_eq!(
|
||||
items
|
||||
.iter()
|
||||
.map(|item| item["role"].as_str().unwrap_or_default())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["user", "assistant"]
|
||||
);
|
||||
|
||||
let chat = read_direct_project_chat_history_at(root.path()).expect("read chat");
|
||||
assert_eq!(
|
||||
chat.messages
|
||||
.iter()
|
||||
.map(|message| message.role.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["user", "assistant"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generic_writer_rows_are_covered_by_the_legacy_whitelist() {
|
||||
let root = init_history_project("generic-writer-shape");
|
||||
crate::append_local_conversation_message_at(
|
||||
root.path(),
|
||||
None,
|
||||
crate::LocalConversationMessage {
|
||||
role: "user".to_string(),
|
||||
content: "通用写入器写的旧格式回合".to_string(),
|
||||
agent_id: None,
|
||||
},
|
||||
)
|
||||
.expect("append legacy row through the generic writer");
|
||||
|
||||
let items =
|
||||
read_direct_project_history_items_at(root.path()).expect("read generic writer row");
|
||||
assert_eq!(
|
||||
items,
|
||||
vec![json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": "通用写入器写的旧格式回合"}],
|
||||
})]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+152
-115
@@ -6,6 +6,10 @@ use std::future::Future;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
mod user_input;
|
||||
pub(crate) use user_input::{chat_with_game_creator_direct_codex, normalize_direct_client_turn_id};
|
||||
|
||||
const MAX_DIRECT_SYSTEM_PROMPT_CHARS: usize = 16 * 1024;
|
||||
const MIN_DIRECT_CLIENT_TURN_ID_CHARS: usize = 6;
|
||||
@@ -264,10 +268,25 @@ fn direct_taonier_regeneration_invocation_sha256(invocation_id: &str) -> String
|
||||
#[derive(Debug)]
|
||||
struct DirectTaonierActiveInvocation {
|
||||
invocation_id: String,
|
||||
/// 登记时刻(Unix 毫秒)。只用于判断一条登记的年龄:见
|
||||
/// [`DIRECT_TAONIER_STALE_GUARD_MIN_AGE_MS`] 与
|
||||
/// [`release_stale_direct_taonier_active_invocation`]。
|
||||
started_at_ms: u64,
|
||||
project_name: Option<String>,
|
||||
started_at: u64,
|
||||
status: String,
|
||||
activity: Option<String>,
|
||||
updated_at: u64,
|
||||
sequence: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct DirectActiveTurnSnapshot {
|
||||
pub(crate) project_path: String,
|
||||
pub(crate) project_name: Option<String>,
|
||||
pub(crate) turn_id: String,
|
||||
pub(crate) started_at: u64,
|
||||
pub(crate) status: String,
|
||||
pub(crate) activity: Option<String>,
|
||||
pub(crate) updated_at: u64,
|
||||
pub(crate) sequence: u64,
|
||||
}
|
||||
|
||||
static DIRECT_TAONIER_ACTIVE_INVOCATIONS: OnceLock<
|
||||
@@ -312,11 +331,23 @@ impl DirectTaonierActiveInvocationGuard {
|
||||
});
|
||||
}
|
||||
None => {
|
||||
let started_at = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_millis() as u64)
|
||||
.unwrap_or_default();
|
||||
active.insert(
|
||||
root.clone(),
|
||||
DirectTaonierActiveInvocation {
|
||||
invocation_id: invocation_id.to_string(),
|
||||
started_at_ms: direct_taonier_active_now_millis(),
|
||||
project_name: root
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.map(str::to_string),
|
||||
started_at,
|
||||
status: "accepted".to_string(),
|
||||
activity: Some("request-accepted".to_string()),
|
||||
updated_at: started_at,
|
||||
sequence: 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -345,6 +376,57 @@ impl Drop for DirectTaonierActiveInvocationGuard {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn list_direct_active_turns() -> Result<Vec<DirectActiveTurnSnapshot>, String> {
|
||||
let active = DIRECT_TAONIER_ACTIVE_INVOCATIONS
|
||||
.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
.lock()
|
||||
.map_err(|_| "Direct 调用身份锁已损坏".to_string())?;
|
||||
let mut turns = active
|
||||
.iter()
|
||||
.map(|(root, invocation)| DirectActiveTurnSnapshot {
|
||||
project_path: root.to_string_lossy().into_owned(),
|
||||
project_name: invocation.project_name.clone(),
|
||||
turn_id: invocation.invocation_id.clone(),
|
||||
started_at: invocation.started_at,
|
||||
status: invocation.status.clone(),
|
||||
activity: invocation.activity.clone(),
|
||||
updated_at: invocation.updated_at,
|
||||
sequence: invocation.sequence,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
turns.sort_by(|left, right| left.project_path.cmp(&right.project_path));
|
||||
Ok(turns)
|
||||
}
|
||||
|
||||
pub(crate) fn update_direct_active_turn(
|
||||
root: &Path,
|
||||
turn_id: &str,
|
||||
status: &str,
|
||||
activity: Option<&str>,
|
||||
sequence: u64,
|
||||
updated_at: u64,
|
||||
) {
|
||||
let Ok(root) = root.canonicalize() else {
|
||||
return;
|
||||
};
|
||||
let Some(active) = DIRECT_TAONIER_ACTIVE_INVOCATIONS.get() else {
|
||||
return;
|
||||
};
|
||||
let Ok(mut active) = active.lock() else {
|
||||
return;
|
||||
};
|
||||
let Some(invocation) = active.get_mut(&root) else {
|
||||
return;
|
||||
};
|
||||
if invocation.invocation_id != turn_id || sequence < invocation.sequence {
|
||||
return;
|
||||
}
|
||||
invocation.status = status.to_string();
|
||||
invocation.activity = activity.map(str::to_string);
|
||||
invocation.updated_at = updated_at;
|
||||
invocation.sequence = sequence;
|
||||
}
|
||||
|
||||
pub(crate) fn direct_taonier_active_invocation_id_at(root: &Path) -> Result<String, String> {
|
||||
let root = root
|
||||
.canonicalize()
|
||||
@@ -389,7 +471,7 @@ pub(crate) fn read_direct_taonier_active_invocation_at(
|
||||
.get(&root)
|
||||
.map(|active| DirectActiveTurnView {
|
||||
client_turn_id: active.invocation_id.clone(),
|
||||
started_at: active.started_at_ms,
|
||||
started_at: active.started_at,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -457,7 +539,7 @@ pub(crate) fn release_stale_direct_taonier_active_invocation(
|
||||
}
|
||||
}
|
||||
if reason == DirectTaonierStaleGuardReason::NeverReachedExecutor {
|
||||
let age_ms = direct_taonier_active_now_millis().saturating_sub(existing.started_at_ms);
|
||||
let age_ms = direct_taonier_active_now_millis().saturating_sub(existing.started_at);
|
||||
if age_ms < DIRECT_TAONIER_STALE_GUARD_MIN_AGE_MS {
|
||||
return Err(format!(
|
||||
"这一轮 Direct 客户端回合刚开始 {} 秒、还在准备中,暂不能强制释放;请稍后再试",
|
||||
@@ -751,7 +833,9 @@ fn prepare_direct_taonier_regeneration_workflow_at(
|
||||
"direct-codex.taonier-package-workflow-prepare",
|
||||
)?;
|
||||
match read_direct_taonier_regeneration_workflow_at(root).map_err(|error| {
|
||||
format!("{DIRECT_TAONIER_RESULT_UNKNOWN_PREFIX} 无法读取陶泥儿整包重生成工作流:{error}")
|
||||
format!(
|
||||
"{DIRECT_TAONIER_RESULT_UNKNOWN_PREFIX} 无法读取陶泥儿整包重生成工作流:{error}"
|
||||
)
|
||||
})? {
|
||||
Some(existing) => match existing.state {
|
||||
DirectTaonierRegenerationWorkflowState::Resetting => {
|
||||
@@ -827,9 +911,7 @@ fn prepare_direct_taonier_regeneration_workflow_at(
|
||||
"{DIRECT_TAONIER_LOCAL_RECONCILIATION_PREFIX} 整包重生成补偿状态缺少 durable rollback journal"
|
||||
)
|
||||
})?;
|
||||
DirectTaonierRegenerationWorkflowPreparation::Compensate {
|
||||
rollback,
|
||||
}
|
||||
DirectTaonierRegenerationWorkflowPreparation::Compensate { rollback }
|
||||
}
|
||||
DirectTaonierRegenerationWorkflowState::Completed => {
|
||||
if existing.invocation_sha256 == invocation_sha256 {
|
||||
@@ -3363,8 +3445,8 @@ pub(crate) async fn ensure_direct_taonier_art_package_at(
|
||||
Some(rollback),
|
||||
format!(
|
||||
"{DIRECT_TAONIER_LOCAL_RECONCILIATION_PREFIX} 无法锚定本轮新背景图,已停止整包重生成:{error}"
|
||||
),
|
||||
));
|
||||
),
|
||||
));
|
||||
}
|
||||
if let Some(workflow) = regeneration_workflow.as_mut() {
|
||||
if let Err(error) =
|
||||
@@ -4316,6 +4398,7 @@ pub(crate) async fn run_direct_game_creator_turn_at_with_creation_type(
|
||||
creation_type,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -4326,6 +4409,7 @@ async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter(
|
||||
creation_type: Option<&str>,
|
||||
turn_emitter: Option<&DirectGameCreatorTurnUpdateEmitter>,
|
||||
audit: Option<&mut DirectCodexTurnAudit>,
|
||||
direct_user_item: Option<serde_json::Value>,
|
||||
) -> Result<String, String> {
|
||||
if !root.is_absolute() || !root.is_dir() {
|
||||
return Err("当前项目目录不存在或不是绝对路径".to_string());
|
||||
@@ -4341,7 +4425,15 @@ async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter(
|
||||
if let Some(emitter) = turn_emitter {
|
||||
emitter.emit("accepted", Some("request-accepted"), None, None);
|
||||
}
|
||||
match run_direct_game_creator_turn_inner(root, prompt, creation_type, turn_emitter, audit).await
|
||||
match run_direct_game_creator_turn_inner(
|
||||
root,
|
||||
prompt,
|
||||
creation_type,
|
||||
turn_emitter,
|
||||
audit,
|
||||
direct_user_item,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(reply) => Ok(reply),
|
||||
Err(failure) => {
|
||||
@@ -4508,7 +4600,10 @@ impl DirectTurnStreamWriter {
|
||||
last_flush: now,
|
||||
});
|
||||
// 新段一出现就立刻落盘 + 下发:位置由这一刻钉死。
|
||||
return self.pending_text.as_ref().map(|pending| pending.item.clone());
|
||||
return self
|
||||
.pending_text
|
||||
.as_ref()
|
||||
.map(|pending| pending.item.clone());
|
||||
}
|
||||
}
|
||||
flushed
|
||||
@@ -4537,6 +4632,7 @@ async fn run_direct_game_creator_turn_inner(
|
||||
creation_type: Option<&str>,
|
||||
turn_emitter: Option<&DirectGameCreatorTurnUpdateEmitter>,
|
||||
audit: Option<&mut DirectCodexTurnAudit>,
|
||||
direct_user_item: Option<serde_json::Value>,
|
||||
) -> Result<String, DirectCodexTurnFailure> {
|
||||
emit_direct_game_creator_progress(root, "codex.turn", "陶泥儿正在处理这条消息");
|
||||
if let Some(emitter) = turn_emitter {
|
||||
@@ -4638,7 +4734,8 @@ async fn run_direct_game_creator_turn_inner(
|
||||
collected.push(tool_call.clone());
|
||||
}
|
||||
// 回合流:工具是**普通元素**,位置在文本段之后(或与相邻工具成块)。
|
||||
let stream_item = stream_writer.push_tool(&tool_call, direct_tool_call_now_ms());
|
||||
let stream_item =
|
||||
stream_writer.push_tool(&tool_call, direct_tool_call_now_ms());
|
||||
spawn_persist_direct_turn_stream_item(&turn_root, &stream_item);
|
||||
emitter.emit_with_stream_items(
|
||||
status,
|
||||
@@ -4659,6 +4756,7 @@ async fn run_direct_game_creator_turn_inner(
|
||||
Some(&client_turn_id),
|
||||
Some(&mut observer),
|
||||
audit,
|
||||
direct_user_item.clone(),
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
@@ -4669,6 +4767,7 @@ async fn run_direct_game_creator_turn_inner(
|
||||
None,
|
||||
None,
|
||||
audit,
|
||||
direct_user_item.clone(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -4685,15 +4784,12 @@ async fn run_direct_game_creator_turn_inner(
|
||||
if let Some(emitter) = turn_emitter {
|
||||
// 最终回复落到本回合最后一条文本段上(原地更新,不新起一段),并随事件下发:
|
||||
// 前端据此把最后一段替换成最终可见回复,流式尾巴与最终回复不会重复。
|
||||
let finalized = finalize_direct_turn_stream_reply_at(
|
||||
root,
|
||||
emitter.turn_id(),
|
||||
&visible_reply,
|
||||
)
|
||||
.ok()
|
||||
.flatten()
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>();
|
||||
let finalized =
|
||||
finalize_direct_turn_stream_reply_at(root, emitter.turn_id(), &visible_reply)
|
||||
.ok()
|
||||
.flatten()
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>();
|
||||
emitter.emit_with_stream_items(
|
||||
"finalizing",
|
||||
Some("response-finalization"),
|
||||
@@ -4977,95 +5073,6 @@ async fn run_direct_game_creator_turn_with_private_editor_credentials(
|
||||
))
|
||||
}
|
||||
|
||||
fn normalize_direct_client_turn_id(client_turn_id: Option<&str>) -> Result<String, String> {
|
||||
let Some(client_turn_id) = client_turn_id else {
|
||||
return Err("Direct 客户端回合缺少稳定 clientTurnId,已拒绝创建可计费生成身份".to_string());
|
||||
};
|
||||
let client_turn_id = client_turn_id.trim();
|
||||
let valid_length = (MIN_DIRECT_CLIENT_TURN_ID_CHARS..=MAX_DIRECT_CLIENT_TURN_ID_CHARS)
|
||||
.contains(&client_turn_id.len());
|
||||
let mut bytes = client_turn_id.bytes();
|
||||
let valid_first = bytes
|
||||
.next()
|
||||
.is_some_and(|byte| byte.is_ascii_alphanumeric());
|
||||
let valid_rest = bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-');
|
||||
if !valid_length || !valid_first || !valid_rest {
|
||||
return Err(format!(
|
||||
"clientTurnId 必须为 {MIN_DIRECT_CLIENT_TURN_ID_CHARS} 到 {MAX_DIRECT_CLIENT_TURN_ID_CHARS} 位 ASCII 字母、数字或连字符,且首位必须为字母或数字"
|
||||
));
|
||||
}
|
||||
Ok(client_turn_id.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn chat_with_game_creator_direct_codex(
|
||||
project_path: String,
|
||||
prompt: String,
|
||||
creation_type: Option<String>,
|
||||
client_turn_id: Option<String>,
|
||||
attachments: Option<Vec<DirectCodexTurnAttachment>>,
|
||||
references: Option<Vec<DirectCodexTurnReference>>,
|
||||
) -> Result<String, String> {
|
||||
let root = Path::new(project_path.trim());
|
||||
let turn_id = normalize_direct_client_turn_id(client_turn_id.as_deref())?;
|
||||
let _active_invocation = DirectTaonierActiveInvocationGuard::enter(root, &turn_id)?;
|
||||
recover_direct_taonier_regeneration_workflow_at(root).map_err(|error| {
|
||||
redact_agent_runtime_error(root, &format!("恢复上一轮陶泥儿整包事务失败:{error}"), 500)
|
||||
})?;
|
||||
let turn_emitter = DirectGameCreatorTurnUpdateEmitter::new(root, turn_id.clone());
|
||||
let mut audit = DirectCodexTurnAudit::start(
|
||||
root,
|
||||
&turn_id,
|
||||
&prompt,
|
||||
attachments.as_deref().unwrap_or_default(),
|
||||
);
|
||||
let attachments = attachments.unwrap_or_default();
|
||||
let references = references.unwrap_or_default();
|
||||
let mut user_prompt = match render_direct_codex_user_prompt(&prompt, &attachments) {
|
||||
Ok(prompt) => prompt,
|
||||
Err(_) if !references.is_empty() && prompt.trim().is_empty() => String::new(),
|
||||
Err(error) => {
|
||||
audit.finish(false);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
if let Some(reference_section) = match render_direct_codex_references_section(root, &references)
|
||||
{
|
||||
Ok(section) => section,
|
||||
Err(error) => {
|
||||
audit.finish(false);
|
||||
return Err(error);
|
||||
}
|
||||
} {
|
||||
if !user_prompt.trim().is_empty() {
|
||||
user_prompt.push_str("\n\n");
|
||||
}
|
||||
user_prompt.push_str(&reference_section);
|
||||
}
|
||||
if user_prompt.trim().is_empty() {
|
||||
audit.finish(false);
|
||||
return Err("聊天内容不能为空".to_string());
|
||||
}
|
||||
let reply = match run_direct_game_creator_turn_at_with_creation_type_and_emitter(
|
||||
root,
|
||||
&user_prompt,
|
||||
creation_type.as_deref(),
|
||||
Some(&turn_emitter),
|
||||
Some(&mut audit),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(reply) => reply,
|
||||
Err(error) => {
|
||||
audit.finish(false);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
audit.finish(true);
|
||||
turn_emitter.emit("completed", Some("none"), Some(reply.clone()), None);
|
||||
Ok(reply)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn chat_with_game_creator_home_direct_codex(
|
||||
prompt: String,
|
||||
@@ -5349,7 +5356,37 @@ mod tests {
|
||||
.lock()
|
||||
.expect("active invocation lock");
|
||||
let entry = active.get_mut(&root).expect("registered invocation");
|
||||
entry.started_at_ms = entry.started_at_ms.saturating_sub(age_ms);
|
||||
entry.started_at = entry.started_at.saturating_sub(age_ms);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_turn_snapshot_tracks_progress_and_is_removed_after_drop() {
|
||||
let root = tempfile::tempdir().expect("active snapshot root");
|
||||
let turn_id = "client-turn-snapshot-0001";
|
||||
let guard = DirectTaonierActiveInvocationGuard::enter(root.path(), turn_id)
|
||||
.expect("active snapshot turn");
|
||||
update_direct_active_turn(
|
||||
root.path(),
|
||||
turn_id,
|
||||
"streaming",
|
||||
Some("response-finalization"),
|
||||
3,
|
||||
42,
|
||||
);
|
||||
let snapshot = list_direct_active_turns()
|
||||
.expect("list active turns")
|
||||
.into_iter()
|
||||
.find(|turn| turn.turn_id == turn_id)
|
||||
.expect("snapshot entry");
|
||||
assert_eq!(snapshot.status, "streaming");
|
||||
assert_eq!(snapshot.activity.as_deref(), Some("response-finalization"));
|
||||
assert_eq!(snapshot.sequence, 3);
|
||||
assert_eq!(snapshot.updated_at, 42);
|
||||
drop(guard);
|
||||
assert!(list_direct_active_turns()
|
||||
.expect("list after completion")
|
||||
.into_iter()
|
||||
.all(|turn| turn.turn_id != turn_id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -0,0 +1,97 @@
|
||||
//! DirectProject 用户输入命令适配器。
|
||||
//!
|
||||
//! Tauri 只在这里接收前端 item,校验与 canonical→prompt 投影交给 user-item
|
||||
//! 深模块,回合编排仍由父模块负责。
|
||||
|
||||
use super::*;
|
||||
|
||||
pub(crate) fn normalize_direct_client_turn_id(
|
||||
client_turn_id: Option<&str>,
|
||||
) -> Result<String, String> {
|
||||
let Some(client_turn_id) = client_turn_id else {
|
||||
return Err("Direct 客户端回合缺少稳定 clientTurnId,已拒绝创建可计费生成身份".to_string());
|
||||
};
|
||||
let client_turn_id = client_turn_id.trim();
|
||||
let valid_length = (MIN_DIRECT_CLIENT_TURN_ID_CHARS..=MAX_DIRECT_CLIENT_TURN_ID_CHARS)
|
||||
.contains(&client_turn_id.len());
|
||||
let mut bytes = client_turn_id.bytes();
|
||||
let valid_first = bytes
|
||||
.next()
|
||||
.is_some_and(|byte| byte.is_ascii_alphanumeric());
|
||||
let valid_rest = bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-');
|
||||
if !valid_length || !valid_first || !valid_rest {
|
||||
return Err(format!(
|
||||
"clientTurnId 必须为 {MIN_DIRECT_CLIENT_TURN_ID_CHARS} 到 {MAX_DIRECT_CLIENT_TURN_ID_CHARS} 位 ASCII 字母、数字或连字符,且首位必须为字母或数字"
|
||||
));
|
||||
}
|
||||
Ok(client_turn_id.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn chat_with_game_creator_direct_codex(
|
||||
project_path: String,
|
||||
prompt: String,
|
||||
mut user_item: DirectCodexUserItem,
|
||||
creation_type: Option<String>,
|
||||
client_turn_id: Option<String>,
|
||||
attachments: Option<Vec<DirectCodexTurnAttachment>>,
|
||||
) -> Result<String, String> {
|
||||
let root = Path::new(project_path.trim());
|
||||
let turn_id = normalize_direct_client_turn_id(client_turn_id.as_deref())?;
|
||||
let _active_invocation = DirectTaonierActiveInvocationGuard::enter(root, &turn_id)?;
|
||||
recover_direct_taonier_regeneration_workflow_at(root).map_err(|error| {
|
||||
redact_agent_runtime_error(root, &format!("恢复上一轮陶泥儿整包事务失败:{error}"), 500)
|
||||
})?;
|
||||
let turn_emitter = DirectGameCreatorTurnUpdateEmitter::new(root, turn_id.clone());
|
||||
let mut audit = DirectCodexTurnAudit::start(
|
||||
root,
|
||||
&turn_id,
|
||||
&prompt,
|
||||
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
|
||||
})?;
|
||||
let user_prompt = direct_codex_user_item_to_prompt(root, &user_item).map_err(|error| {
|
||||
audit.finish(false);
|
||||
error
|
||||
})?;
|
||||
if user_prompt.trim().is_empty() {
|
||||
audit.finish(false);
|
||||
return Err("聊天内容不能为空".to_string());
|
||||
}
|
||||
let canonical_user_item =
|
||||
Some(serde_json::to_value(user_item).map_err(|error| error.to_string())?);
|
||||
let reply = match run_direct_game_creator_turn_at_with_creation_type_and_emitter(
|
||||
root,
|
||||
&user_prompt,
|
||||
creation_type.as_deref(),
|
||||
Some(&turn_emitter),
|
||||
Some(&mut audit),
|
||||
canonical_user_item,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(reply) => reply,
|
||||
Err(error) => {
|
||||
audit.finish(false);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
audit.finish(true);
|
||||
turn_emitter.emit("completed", Some("none"), Some(reply.clone()), None);
|
||||
Ok(reply)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6519,6 +6519,7 @@ impl PlatformArtSliceContractRollback {
|
||||
|
||||
fn validate_strict_platform_art_spritesheet_contract(
|
||||
slices: &[PreparedPlatformArtAssetSlice],
|
||||
slice_warning: Option<&str>,
|
||||
canvas_context: &ExternalCanvasGenerationContext,
|
||||
canvas_project_id: Option<&str>,
|
||||
resource_id: Option<&str>,
|
||||
@@ -6532,7 +6533,11 @@ fn validate_strict_platform_art_spritesheet_contract(
|
||||
has_visible_pixels: bool,
|
||||
) -> Result<(), String> {
|
||||
if slices.is_empty() {
|
||||
return Err("spritesheet 图集至少需要一个独立切片".to_string());
|
||||
return Err(slice_warning
|
||||
.map(str::trim)
|
||||
.filter(|warning| !warning.is_empty())
|
||||
.map(|warning| format!("spritesheet 图集至少需要一个独立切片;原始切片告警:{warning}"))
|
||||
.unwrap_or_else(|| "spritesheet 图集至少需要一个独立切片".to_string()));
|
||||
}
|
||||
let resource_id = resource_id
|
||||
.map(str::trim)
|
||||
@@ -7337,6 +7342,7 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook(
|
||||
if require_complete_core_slices {
|
||||
validate_strict_platform_art_spritesheet_contract(
|
||||
&slices,
|
||||
slice_warning.as_deref(),
|
||||
&canvas_context,
|
||||
canvas_project_id.as_deref(),
|
||||
resource_id.as_deref(),
|
||||
@@ -9798,6 +9804,7 @@ mod canvas_generation_tests {
|
||||
.collect::<Vec<_>>();
|
||||
validate_strict_platform_art_spritesheet_contract(
|
||||
&slices,
|
||||
None,
|
||||
&canvas_context,
|
||||
Some("canvas-project"),
|
||||
Some("spritesheet-resource"),
|
||||
@@ -9813,6 +9820,35 @@ mod canvas_generation_tests {
|
||||
.expect("valid slice identities and pixel evidence do not require a fixed layout");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_spritesheet_contract_preserves_slice_warning_when_empty() {
|
||||
let canvas_context = ExternalCanvasGenerationContext {
|
||||
project_id: "canvas-project".to_string(),
|
||||
asset_folder_id: "asset-folder".to_string(),
|
||||
canvas_name: "empty-slice-warning".to_string(),
|
||||
};
|
||||
let error = validate_strict_platform_art_spritesheet_contract(
|
||||
&[],
|
||||
Some("识别出的素材数量超过输出上限:86,最多允许 256 个"),
|
||||
&canvas_context,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
"route",
|
||||
"kind",
|
||||
None,
|
||||
&[],
|
||||
false,
|
||||
false,
|
||||
)
|
||||
.expect_err("empty slices must expose the original platform warning");
|
||||
|
||||
assert!(error.contains("至少需要一个独立切片"));
|
||||
assert!(error.contains("原始切片告警"));
|
||||
assert!(error.contains("识别出的素材数量超过输出上限:86,最多允许 256 个"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_spritesheet_contract_rejects_an_opaque_slice() {
|
||||
let canvas_context = ExternalCanvasGenerationContext {
|
||||
@@ -9850,6 +9886,7 @@ mod canvas_generation_tests {
|
||||
|
||||
let error = validate_strict_platform_art_spritesheet_contract(
|
||||
&slices,
|
||||
None,
|
||||
&canvas_context,
|
||||
Some("canvas-project"),
|
||||
Some("spritesheet-resource"),
|
||||
@@ -12449,7 +12486,10 @@ mod canvas_generation_tests {
|
||||
.expect("init strict slice project");
|
||||
let path = root.join("assets/art-spritesheet.png");
|
||||
fs::write(&path, b"old-image").expect("write old spritesheet");
|
||||
let prepared = prepared_replacement(root, b"new-image");
|
||||
let mut prepared = prepared_replacement(root, b"new-image");
|
||||
prepared.slice_warning = Some(
|
||||
"图标 spritesheet 识别出的素材数量超过输出上限:86,最多允许 256 个。".to_string(),
|
||||
);
|
||||
|
||||
let error = commit_prepared_platform_art_asset_strict_slices_at(
|
||||
root,
|
||||
@@ -12460,6 +12500,8 @@ mod canvas_generation_tests {
|
||||
.expect_err("strict spritesheet commit must require at least one slice");
|
||||
|
||||
assert!(error.contains("至少需要一个独立切片"));
|
||||
assert!(error.contains("原始切片告警"));
|
||||
assert!(error.contains("识别出的素材数量超过输出上限:86,最多允许 256 个"));
|
||||
assert_eq!(fs::read(path).expect("read preserved sheet"), b"old-image");
|
||||
assert!(!root
|
||||
.join("assets/art-spritesheet-slices/manifest.json")
|
||||
|
||||
@@ -129,15 +129,23 @@ impl DirectGameCreatorTurnUpdateEmitter {
|
||||
if !status_is_allowed || !activity_is_allowed {
|
||||
return;
|
||||
}
|
||||
let Some(app) = GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.get() else {
|
||||
return;
|
||||
};
|
||||
let sequence = self.sequence.fetch_add(1, Ordering::AcqRel) + 1;
|
||||
let updated_at = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis()
|
||||
.min(u64::MAX as u128) as u64;
|
||||
update_direct_active_turn(
|
||||
Path::new(&self.project_path),
|
||||
&self.turn_id,
|
||||
status,
|
||||
activity,
|
||||
sequence,
|
||||
updated_at,
|
||||
);
|
||||
let Some(app) = GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.get() else {
|
||||
return;
|
||||
};
|
||||
let _ = app.emit(
|
||||
"game-creator-direct-turn-update",
|
||||
GameCreatorDirectTurnUpdateEvent {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
use super::*;
|
||||
use crate::agent::read_direct_project_chat_history_at;
|
||||
use crate::agent::{
|
||||
direct_codex_canonical_project_identity, read_direct_project_chat_history_at,
|
||||
read_direct_project_last_item_id_at,
|
||||
};
|
||||
use crate::ui_editor::resource::font::FontAsset;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
@@ -5324,6 +5327,63 @@ pub(crate) async fn read_direct_turn_stream(
|
||||
.map_err(|error| format!("读取回合流历史后台任务失败:{error}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn list_game_creator_direct_active_turns(
|
||||
) -> Result<Vec<DirectActiveTurnSnapshot>, String> {
|
||||
list_direct_active_turns()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn subscribe_direct_project_thread(
|
||||
project_path: String,
|
||||
) -> Result<DirectThreadSubscriptionBootstrap, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let root = Path::new(project_path.trim());
|
||||
enforce_project_permission_policy(root, "conversation.read")?;
|
||||
let (canonical_root, _) = direct_codex_canonical_project_identity(root)?;
|
||||
let thread_root = canonical_root
|
||||
.to_str()
|
||||
.and_then(|value| value.strip_prefix("\\\\?\\"))
|
||||
.map(Path::new)
|
||||
.unwrap_or(canonical_root.as_path());
|
||||
let thread_id = thread_root.to_string_lossy().into_owned();
|
||||
let mut bootstrap = subscribe_direct_thread(&thread_id);
|
||||
if bootstrap.last_completed_item_id.is_none() {
|
||||
bootstrap.last_completed_item_id = read_direct_project_last_item_id_at(root)?;
|
||||
}
|
||||
Ok(bootstrap)
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("订阅 DirectProject 线程后台任务失败:{error}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn consume_direct_project_thread(
|
||||
subscription_id: String,
|
||||
) -> Result<DirectThreadConsumeResult, String> {
|
||||
consume_direct_thread(subscription_id.trim())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn read_direct_project_history_slice(
|
||||
project_path: String,
|
||||
before_item_id: Option<String>,
|
||||
limit: Option<usize>,
|
||||
) -> Result<DirectThreadHistorySlice, String> {
|
||||
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(
|
||||
root,
|
||||
before_item_id.as_deref(),
|
||||
limit.unwrap_or(20),
|
||||
)?;
|
||||
Ok(DirectThreadHistorySlice { items, has_more })
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("读取 DirectProject 历史切片后台任务失败:{error}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn append_local_conversation_message(
|
||||
project_path: String,
|
||||
|
||||
@@ -2618,6 +2618,7 @@ fn main() {
|
||||
app.manage(gui_owner_lock);
|
||||
setup_log.append("startup.runner.start.begin");
|
||||
set_game_creator_agent_runtime_update_app_handle(app.handle().clone());
|
||||
set_direct_thread_manager_app_handle(app.handle().clone());
|
||||
let manifest_event_sink =
|
||||
start_game_creator_manifest_invalidation_event_sink(app.handle().clone())?;
|
||||
attach_external_agent_runner_gui_owner(&manifest_event_sink, &gui_owner_epoch)
|
||||
@@ -2785,6 +2786,10 @@ fn main() {
|
||||
read_direct_tool_calls,
|
||||
read_direct_turn_stream,
|
||||
read_agent_runtime_error_detail,
|
||||
list_game_creator_direct_active_turns,
|
||||
subscribe_direct_project_thread,
|
||||
consume_direct_project_thread,
|
||||
read_direct_project_history_slice,
|
||||
append_local_conversation_message,
|
||||
append_direct_project_conversation_message,
|
||||
build_local_project_index,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1382,3 +1382,14 @@ export type TauriInvoke = <T>(
|
||||
command: string,
|
||||
args?: Record<string, unknown>,
|
||||
) => Promise<T>;
|
||||
|
||||
export type GameCreatorDirectActiveTurn = {
|
||||
projectPath: string;
|
||||
projectName?: string | null;
|
||||
turnId: string;
|
||||
startedAt: number;
|
||||
status: string;
|
||||
activity?: string | null;
|
||||
updatedAt: number;
|
||||
sequence: number;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import type { GameCreatorDirectActiveTurn, TauriInvoke } from '../../app/types';
|
||||
|
||||
/**
|
||||
* 轮询间隔:注册表是进程内只读快照,一次查询只是一次 IPC + 一次内存遍历。
|
||||
* "哪些项目正在跑"不值得再建一套事件流,而且轮询能在丢事件时自愈。
|
||||
*/
|
||||
export const DIRECT_ACTIVE_TURNS_POLL_INTERVAL_MS = 5_000;
|
||||
|
||||
/**
|
||||
* 单次刷新里的读取尝试次数。快照读取失败最多重试 3 次,3 次全部失败才把
|
||||
* "读不到"告诉用户;但即便告诉,也只能说读取失败,不得改写成业务失败、
|
||||
* 权限问题或审批结论。
|
||||
*/
|
||||
export const DIRECT_ACTIVE_TURNS_READ_ATTEMPTS = 3;
|
||||
const DIRECT_ACTIVE_TURNS_READ_RETRY_DELAY_MS = 300;
|
||||
|
||||
/**
|
||||
* 当前进程里仍在跑的 Direct 回合。
|
||||
*
|
||||
* 回合属于项目而不是页面:离开项目界面不会终止它,所以"谁在跑"必须从 Rust 的
|
||||
* 活动回合注册表读,而不是从当前页面的组件状态推断。读取失败保留上一份快照——
|
||||
* 读不到不等于"没有在跑",调用方不能据此阻断发送或清空状态。
|
||||
*/
|
||||
export function useDirectActiveTurns({
|
||||
invoke,
|
||||
enabled,
|
||||
pollIntervalMs = DIRECT_ACTIVE_TURNS_POLL_INTERVAL_MS,
|
||||
}: {
|
||||
invoke: TauriInvoke | null | undefined;
|
||||
enabled: boolean;
|
||||
pollIntervalMs?: number;
|
||||
}) {
|
||||
const [activeTurns, setActiveTurns] = useState<GameCreatorDirectActiveTurn[]>(
|
||||
[],
|
||||
);
|
||||
const [snapshotReadFailed, setSnapshotReadFailed] = useState(false);
|
||||
const mountedRef = useRef(true);
|
||||
const inFlightRef = useRef<Promise<void> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const refreshActiveTurns = useCallback(async () => {
|
||||
if (!invoke) {
|
||||
return;
|
||||
}
|
||||
// 单飞:轮询与"回合刚开始/刚结束"的主动刷新不叠成两个在途请求。
|
||||
if (inFlightRef.current) {
|
||||
return inFlightRef.current;
|
||||
}
|
||||
const request = (async () => {
|
||||
for (
|
||||
let attempt = 1;
|
||||
attempt <= DIRECT_ACTIVE_TURNS_READ_ATTEMPTS;
|
||||
attempt++
|
||||
) {
|
||||
try {
|
||||
const turns = await invoke<GameCreatorDirectActiveTurn[]>(
|
||||
'list_game_creator_direct_active_turns',
|
||||
);
|
||||
if (!mountedRef.current) {
|
||||
return;
|
||||
}
|
||||
setActiveTurns(Array.isArray(turns) ? turns : []);
|
||||
setSnapshotReadFailed(false);
|
||||
inFlightRef.current = null;
|
||||
return;
|
||||
} catch {
|
||||
if (attempt < DIRECT_ACTIVE_TURNS_READ_ATTEMPTS) {
|
||||
await new Promise((resolve) =>
|
||||
window.setTimeout(
|
||||
resolve,
|
||||
DIRECT_ACTIVE_TURNS_READ_RETRY_DELAY_MS * attempt,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 三次都读不到:保留上一份快照(读不到不等于没有在跑),只标记"本次没读到"。
|
||||
if (mountedRef.current) {
|
||||
setSnapshotReadFailed(true);
|
||||
}
|
||||
inFlightRef.current = null;
|
||||
})();
|
||||
inFlightRef.current = request;
|
||||
return request;
|
||||
}, [invoke]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !invoke) {
|
||||
setActiveTurns([]);
|
||||
setSnapshotReadFailed(false);
|
||||
return;
|
||||
}
|
||||
void refreshActiveTurns();
|
||||
const timer = window.setInterval(
|
||||
() => void refreshActiveTurns(),
|
||||
Math.max(1_000, pollIntervalMs),
|
||||
);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [enabled, invoke, pollIntervalMs, refreshActiveTurns]);
|
||||
|
||||
return { activeTurns, refreshActiveTurns, snapshotReadFailed };
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import type { GameCreatorDirectActiveTurn } from '../../app/types';
|
||||
import { projectNameFromPath } from '../agent-runtime';
|
||||
import { projectPathsMatchForInvalidation } from '../project-summary/projectPath';
|
||||
|
||||
/**
|
||||
* 左上角的"正在运行的项目"面板。
|
||||
*
|
||||
* 数据来自 Rust 的活动回合注册表(同一个只读快照也用于重新进入项目时的进度重连),
|
||||
* 面板只负责呈现:项目名、阶段、已运行时长,以及点击进入该项目。没有在跑回合时
|
||||
* 整块不渲染,不留空白占位。
|
||||
*/
|
||||
export type ActiveProjectRunsPanelProps = {
|
||||
activeTurns: GameCreatorDirectActiveTurn[];
|
||||
currentProjectPath?: string | null;
|
||||
readFailed?: boolean;
|
||||
onOpenProject?: (projectPath: string) => void;
|
||||
};
|
||||
|
||||
const ACTIVE_TURN_STATUS_LABELS: Record<string, string> = {
|
||||
accepted: '已受理',
|
||||
running: '创作中',
|
||||
streaming: '生成中',
|
||||
finalizing: '收尾中',
|
||||
completed: '已完成',
|
||||
failed: '已失败',
|
||||
};
|
||||
|
||||
function activeTurnStatusLabel(status: string) {
|
||||
return ACTIVE_TURN_STATUS_LABELS[status] ?? '创作中';
|
||||
}
|
||||
|
||||
function formatActiveTurnElapsed(startedAt: number, now: number) {
|
||||
const elapsedMs = now - startedAt;
|
||||
if (!Number.isFinite(elapsedMs) || elapsedMs < 0) {
|
||||
return '';
|
||||
}
|
||||
const totalMinutes = Math.floor(elapsedMs / 60_000);
|
||||
if (totalMinutes < 1) {
|
||||
return '不到 1 分钟';
|
||||
}
|
||||
if (totalMinutes < 60) {
|
||||
return `${totalMinutes} 分钟`;
|
||||
}
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
const minutes = totalMinutes % 60;
|
||||
return minutes === 0 ? `${hours} 小时` : `${hours} 小时 ${minutes} 分`;
|
||||
}
|
||||
|
||||
function activeTurnDisplayName(turn: GameCreatorDirectActiveTurn) {
|
||||
const snapshotName = turn.projectName?.trim();
|
||||
return snapshotName || projectNameFromPath(turn.projectPath);
|
||||
}
|
||||
|
||||
export function ActiveProjectRunsPanel({
|
||||
activeTurns,
|
||||
currentProjectPath = null,
|
||||
readFailed = false,
|
||||
onOpenProject,
|
||||
}: ActiveProjectRunsPanelProps) {
|
||||
if (activeTurns.length === 0) {
|
||||
if (!readFailed) {
|
||||
return null;
|
||||
}
|
||||
// 三次都没读到快照:只说"没读到",不改写成业务、权限或审批结论。
|
||||
return (
|
||||
<aside className="launcher-runs-panel" aria-label="正在运行的项目">
|
||||
<span className="launcher-runs-panel-note" role="status">
|
||||
未能读取正在运行的项目
|
||||
</span>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const orderedTurns = [...activeTurns].sort(
|
||||
(left, right) => left.startedAt - right.startedAt,
|
||||
);
|
||||
return (
|
||||
<aside className="launcher-runs-panel" aria-label="正在运行的项目">
|
||||
<header className="launcher-runs-panel-header">
|
||||
<span className="launcher-runs-panel-dot" aria-hidden="true" />
|
||||
<strong>正在运行</strong>
|
||||
</header>
|
||||
<ul className="launcher-runs-panel-list">
|
||||
{orderedTurns.map((turn) => {
|
||||
const name = activeTurnDisplayName(turn);
|
||||
const elapsed = formatActiveTurnElapsed(turn.startedAt, now);
|
||||
const isCurrent = Boolean(
|
||||
currentProjectPath &&
|
||||
projectPathsMatchForInvalidation(
|
||||
turn.projectPath,
|
||||
currentProjectPath,
|
||||
),
|
||||
);
|
||||
return (
|
||||
<li key={`${turn.projectPath}:${turn.turnId}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="launcher-runs-panel-item"
|
||||
aria-current={isCurrent ? 'true' : undefined}
|
||||
disabled={!onOpenProject}
|
||||
onClick={() => onOpenProject?.(turn.projectPath)}
|
||||
>
|
||||
<span className="launcher-runs-panel-name" title={name}>
|
||||
{name}
|
||||
</span>
|
||||
<span className="launcher-runs-panel-meta">
|
||||
{[activeTurnStatusLabel(turn.status), elapsed]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -25,8 +25,10 @@ import {
|
||||
type ProjectManifestSnapshotSource,
|
||||
rereadAuthoritativeProjectManifestSnapshot,
|
||||
} from '../../view/project-development/projectResourceLiveUpdateModel';
|
||||
import { useDirectActiveTurns } from '../agent-runtime/directActiveTurns';
|
||||
import { RuntimeConfigDialog } from '../runtime-config/RuntimeConfigDialog';
|
||||
import { AccountWalletBar, AccountWalletDialogs } from './AccountWallet';
|
||||
import { ActiveProjectRunsPanel } from './ActiveProjectRunsPanel';
|
||||
import {
|
||||
DeveloperAgentDialogs,
|
||||
DeveloperAgentPanel,
|
||||
@@ -96,6 +98,11 @@ export function WorkspaceLauncherShell({
|
||||
homeCreationBusy,
|
||||
homeCreationRecoverableProjectPath,
|
||||
} = homeProject;
|
||||
const directInvoke = resolveTauriInvoke();
|
||||
const { activeTurns, snapshotReadFailed } = useDirectActiveTurns({
|
||||
invoke: directInvoke,
|
||||
enabled: true,
|
||||
});
|
||||
const switchedToGameRuntime =
|
||||
gameRuntimeSwitch !== null &&
|
||||
currentProjectContext !== null &&
|
||||
@@ -517,6 +524,16 @@ export function WorkspaceLauncherShell({
|
||||
</header>
|
||||
) : null}
|
||||
|
||||
<ActiveProjectRunsPanel
|
||||
activeTurns={activeTurns}
|
||||
currentProjectPath={currentProjectContext?.projectPath ?? null}
|
||||
readFailed={snapshotReadFailed}
|
||||
onOpenProject={(nextProjectPath) => {
|
||||
setProjectPath(nextProjectPath);
|
||||
void openProject(nextProjectPath, 'open');
|
||||
}}
|
||||
/>
|
||||
|
||||
{launcherView === 'home' ? (
|
||||
<HomeView
|
||||
hasPromo={launcherNotifications.length > 0}
|
||||
|
||||
+43
-21
@@ -192,23 +192,29 @@ function TurnStreamSequence({
|
||||
{runs.map((run, index) =>
|
||||
run.kind === 'text' ? (
|
||||
toolsOnly ? null : (
|
||||
<div
|
||||
key={run.key}
|
||||
className={className ? `message message--assistant ${className}` : 'message message--assistant'}
|
||||
>
|
||||
<ChatMarkdownMessage
|
||||
role="assistant"
|
||||
text={run.text}
|
||||
streaming={active && index === runs.length - 1}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
key={run.key}
|
||||
className={
|
||||
className
|
||||
? `message message--assistant ${className}`
|
||||
: 'message message--assistant'
|
||||
}
|
||||
>
|
||||
<ChatMarkdownMessage
|
||||
role="assistant"
|
||||
text={run.text}
|
||||
streaming={active && index === runs.length - 1}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<ToolCallGroup
|
||||
key={run.key}
|
||||
calls={run.callIds
|
||||
.map((callId) => callsById.get(callId))
|
||||
.filter((call): call is GameCreatorDirectToolCall => Boolean(call))}
|
||||
.filter((call): call is GameCreatorDirectToolCall =>
|
||||
Boolean(call),
|
||||
)}
|
||||
userSentAt={userSentAt}
|
||||
active={active}
|
||||
className="message-tool-call"
|
||||
@@ -255,6 +261,7 @@ type ProjectSupervisorViewProps = RuntimePanelProps & {
|
||||
directProcessDetail?: string;
|
||||
directProcessKey?: string;
|
||||
hiddenConversationCount: number;
|
||||
hasEarlierConversationMessages?: boolean;
|
||||
messagesRef: RefObject<HTMLDivElement | null>;
|
||||
needsUserInput: boolean;
|
||||
onCancelConfirmation: () => void;
|
||||
@@ -337,6 +344,7 @@ export function ProjectSupervisorView({
|
||||
directProcessDetail = '',
|
||||
directProcessKey = '',
|
||||
hiddenConversationCount,
|
||||
hasEarlierConversationMessages = false,
|
||||
messagesRef,
|
||||
needsUserInput,
|
||||
onCancelConfirmation,
|
||||
@@ -449,11 +457,13 @@ export function ProjectSupervisorView({
|
||||
let liveToolCallTurnId = '';
|
||||
// 老项目里持久化的 assistant 消息 id 是 Codex 原始 id(不是 `direct-codex:<turnId>:assistant`),
|
||||
// 因此锚不上;这些工具的回合不能丢,统一收集后渲染在列表末尾。
|
||||
const unanchoredToolCallsByTurn = new Map<string, GameCreatorDirectToolCall[]>();
|
||||
const unanchoredToolCallsByTurn = new Map<
|
||||
string,
|
||||
GameCreatorDirectToolCall[]
|
||||
>();
|
||||
// 正在跑的回合:它的流条目直接渲染在消息列表末尾,直到用户消息落盘、由那条消息接管。
|
||||
const liveStreamTurnId = streamTurnId && turnStreamByTurn.has(streamTurnId)
|
||||
? streamTurnId
|
||||
: '';
|
||||
const liveStreamTurnId =
|
||||
streamTurnId && turnStreamByTurn.has(streamTurnId) ? streamTurnId : '';
|
||||
const liveStreamItems = liveStreamTurnId
|
||||
? (turnStreamByTurn.get(liveStreamTurnId) ?? [])
|
||||
: [];
|
||||
@@ -503,7 +513,10 @@ export function ProjectSupervisorView({
|
||||
}
|
||||
for (const call of toolCalls) {
|
||||
if (call.turnId === turnId) {
|
||||
first = Math.min(first, Number(call.startedAt) || Number.POSITIVE_INFINITY);
|
||||
first = Math.min(
|
||||
first,
|
||||
Number(call.startedAt) || Number.POSITIVE_INFINITY,
|
||||
);
|
||||
}
|
||||
}
|
||||
return Number.isFinite(first) ? first : 0;
|
||||
@@ -725,13 +738,15 @@ export function ProjectSupervisorView({
|
||||
aria-label={directCodex ? '陶泥儿消息' : '项目总控消息'}
|
||||
onScroll={onScroll}
|
||||
>
|
||||
{hiddenConversationCount > 0 ? (
|
||||
{hiddenConversationCount > 0 || hasEarlierConversationMessages ? (
|
||||
<button
|
||||
type="button"
|
||||
className="message-history-more"
|
||||
onClick={onShowEarlierMessages}
|
||||
>
|
||||
{`显示更早 · 还有 ${hiddenConversationCount} 条对话`}
|
||||
{hiddenConversationCount > 0
|
||||
? `显示更早 · 还有 ${hiddenConversationCount} 条对话`
|
||||
: '显示更早的对话'}
|
||||
</button>
|
||||
) : null}
|
||||
{initialSupervisorMessage.trim() &&
|
||||
@@ -758,7 +773,8 @@ export function ProjectSupervisorView({
|
||||
? directCodexTurnIdFromUserMessageId(message.messageId)
|
||||
: null;
|
||||
const streamTurnIdForMessage =
|
||||
(userTurnId ?? anchoredTurnId) ??
|
||||
userTurnId ??
|
||||
anchoredTurnId ??
|
||||
turnIdByMessageIndex.get(index) ??
|
||||
null;
|
||||
const turnStream = streamTurnIdForMessage
|
||||
@@ -811,7 +827,10 @@ export function ProjectSupervisorView({
|
||||
items={turnStream}
|
||||
toolCalls={toolCalls}
|
||||
toolsOnly={streamTextIncomplete}
|
||||
active={Boolean(activeTurnId) && streamTurnIdForMessage === activeTurnId}
|
||||
active={
|
||||
Boolean(activeTurnId) &&
|
||||
streamTurnIdForMessage === activeTurnId
|
||||
}
|
||||
userSentAt={userMessageUpdatedAtForTurn(
|
||||
streamTurnIdForMessage ?? '',
|
||||
)}
|
||||
@@ -837,7 +856,10 @@ export function ProjectSupervisorView({
|
||||
没有流的回合保持原样:跟在回合最后一条消息之后。 */}
|
||||
{ownsStream && streamTurnIdForMessage
|
||||
? renderTurnUsage(streamTurnIdForMessage)
|
||||
: !streamCovered && isTurnEnd && !isActiveTurn && anchoredTurnId
|
||||
: !streamCovered &&
|
||||
isTurnEnd &&
|
||||
!isActiveTurn &&
|
||||
anchoredTurnId
|
||||
? renderTurnUsage(anchoredTurnId)
|
||||
: null}
|
||||
</Fragment>
|
||||
|
||||
+6
-2
@@ -157,6 +157,7 @@ type ProjectWorkspaceChatPaneProps = {
|
||||
handleRevealCurrentProjectDirectory: () => Promise<void>;
|
||||
handleRuntimeConfigOpen: () => void;
|
||||
hiddenConversationCount: number;
|
||||
hasEarlierConversationMessages?: boolean;
|
||||
llmConfigStatus: GameCreatorLlmConfigStatus | null;
|
||||
loadProjectConversation: (
|
||||
nextProjectPath: string,
|
||||
@@ -256,6 +257,7 @@ export function ProjectWorkspaceChatPane({
|
||||
handleRevealCurrentProjectDirectory,
|
||||
handleRuntimeConfigOpen,
|
||||
hiddenConversationCount,
|
||||
hasEarlierConversationMessages = false,
|
||||
llmConfigStatus,
|
||||
loadProjectConversation,
|
||||
localProject,
|
||||
@@ -809,13 +811,15 @@ export function ProjectWorkspaceChatPane({
|
||||
</div>
|
||||
) : null}
|
||||
<div className="message-list" onScroll={handleConversationScroll}>
|
||||
{hiddenConversationCount > 0 ? (
|
||||
{hiddenConversationCount > 0 || hasEarlierConversationMessages ? (
|
||||
<button
|
||||
type="button"
|
||||
className="message-history-more"
|
||||
onClick={showEarlierConversationMessages}
|
||||
>
|
||||
{`显示更早 · 还有 ${hiddenConversationCount} 条对话`}
|
||||
{hiddenConversationCount > 0
|
||||
? `显示更早 · 还有 ${hiddenConversationCount} 条对话`
|
||||
: '显示更早的对话'}
|
||||
</button>
|
||||
) : null}
|
||||
{visibleMessages.map((message, index) => (
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user