接入DirectProject线程事件消费命令
将app-server item与turn事件接入Thread Manager 新增subscribe、consume和历史切片Tauri命令 保证响应item持久化成功后才发布完成事件
This commit is contained in:
@@ -2771,6 +2771,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 {
|
||||
@@ -2822,6 +2837,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() {
|
||||
@@ -2864,6 +2888,20 @@ impl CodexAppServerConnection {
|
||||
})?
|
||||
.map_err(platform_llm::LlmError::InvalidRequest)?;
|
||||
direct_project_history.complete_item(&item);
|
||||
let item_id = item
|
||||
.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: "item.completed".to_string(),
|
||||
turn_id: turn_id.clone(),
|
||||
item_id,
|
||||
payload: serde_json::json!({ "item": item }),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Some(CodexTurnEvent::Activity(activity)) => {
|
||||
@@ -2926,6 +2964,25 @@ impl CodexAppServerConnection {
|
||||
self.inner.workspace_mode.passive_item_boundary_name(),
|
||||
)));
|
||||
}
|
||||
if !completed
|
||||
&& self.inner.workspace_mode
|
||||
== CodexAppServerWorkspaceMode::DirectProject
|
||||
{
|
||||
let item_id = item
|
||||
.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: "item.started".to_string(),
|
||||
turn_id: turn_id.clone(),
|
||||
item_id,
|
||||
payload: serde_json::json!({ "item": item }),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(CodexTurnEvent::Terminal(params)) => {
|
||||
@@ -2942,11 +2999,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: params.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
match status {
|
||||
"completed" => {
|
||||
return final_text
|
||||
.filter(|text| !text.trim().is_empty())
|
||||
|
||||
@@ -512,6 +512,24 @@ 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_chat_history_at(
|
||||
root: &Path,
|
||||
) -> Result<LocalConversationResult, String> {
|
||||
|
||||
@@ -7,12 +7,17 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use uuid::Uuid;
|
||||
|
||||
const DEFAULT_MAX_EVENTS: usize = 8_192;
|
||||
const DEFAULT_MAX_BYTES: usize = 8 * 1024 * 1024;
|
||||
|
||||
pub(crate) const SUBSCRIPTION_EXPIRED: &str = "SUBSCRIPTION_EXPIRED";
|
||||
pub(crate) const DIRECT_THREAD_NOTIFY_EVENT: &str = "game-creator-direct-thread-notify";
|
||||
|
||||
static DIRECT_THREAD_MANAGER: OnceLock<Mutex<DirectThreadManager>> = OnceLock::new();
|
||||
static DIRECT_THREAD_MANAGER_APP_HANDLE: OnceLock<tauri::AppHandle> = OnceLock::new();
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -48,6 +53,13 @@ pub(crate) struct DirectThreadConsumeResult {
|
||||
pub(crate) events: Vec<DirectThreadRawEvent>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct DirectThreadHistorySlice {
|
||||
pub(crate) items: Vec<Value>,
|
||||
pub(crate) has_more: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct StoredEvent {
|
||||
event: DirectThreadRawEvent,
|
||||
@@ -105,7 +117,6 @@ impl DirectThreadManager {
|
||||
Self::with_limits(DEFAULT_MAX_EVENTS, DEFAULT_MAX_BYTES)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn with_limits(max_events: usize, max_bytes: usize) -> Self {
|
||||
Self {
|
||||
threads: HashMap::new(),
|
||||
@@ -170,6 +181,13 @@ impl DirectThreadManager {
|
||||
}
|
||||
}
|
||||
|
||||
fn subscriber_ids(&self, thread_id: &str) -> Vec<String> {
|
||||
self.threads
|
||||
.get(thread_id)
|
||||
.map(|thread| thread.subscribers.keys().cloned().collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub(crate) fn consume(
|
||||
&mut self,
|
||||
subscription_id: &str,
|
||||
@@ -345,6 +363,54 @@ impl DirectThreadManager {
|
||||
}
|
||||
}
|
||||
|
||||
fn global_direct_thread_manager() -> &'static Mutex<DirectThreadManager> {
|
||||
DIRECT_THREAD_MANAGER.get_or_init(|| Mutex::new(DirectThreadManager::new()))
|
||||
}
|
||||
|
||||
pub(crate) fn set_direct_thread_manager_app_handle(app: tauri::AppHandle) {
|
||||
let _ = DIRECT_THREAD_MANAGER_APP_HANDLE.set(app);
|
||||
}
|
||||
|
||||
pub(crate) fn append_direct_thread_event(
|
||||
thread_id: &str,
|
||||
draft: DirectThreadRawEventDraft,
|
||||
) -> DirectThreadRawEvent {
|
||||
let (event, subscriber_ids) = {
|
||||
let mut manager = global_direct_thread_manager()
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
let event = manager.append(thread_id, draft);
|
||||
let subscriber_ids = manager.subscriber_ids(thread_id);
|
||||
(event, subscriber_ids)
|
||||
};
|
||||
if let Some(app) = DIRECT_THREAD_MANAGER_APP_HANDLE.get() {
|
||||
for subscription_id in subscriber_ids {
|
||||
let _ = tauri::Emitter::emit(
|
||||
app,
|
||||
DIRECT_THREAD_NOTIFY_EVENT,
|
||||
serde_json::json!({ "subscriptionId": subscription_id }),
|
||||
);
|
||||
}
|
||||
}
|
||||
event
|
||||
}
|
||||
|
||||
pub(crate) fn subscribe_direct_thread(thread_id: &str) -> DirectThreadSubscriptionBootstrap {
|
||||
global_direct_thread_manager()
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
.subscribe(thread_id)
|
||||
}
|
||||
|
||||
pub(crate) fn consume_direct_thread(
|
||||
subscription_id: &str,
|
||||
) -> Result<DirectThreadConsumeResult, String> {
|
||||
global_direct_thread_manager()
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
.consume(subscription_id)
|
||||
}
|
||||
|
||||
fn request_id(event: &DirectThreadRawEvent) -> Option<String> {
|
||||
event
|
||||
.payload
|
||||
|
||||
@@ -5241,6 +5241,43 @@ pub(crate) async fn read_direct_project_conversation(
|
||||
.map_err(|error| format!("读取 DirectProject 历史后台任务失败:{error}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn subscribe_direct_project_thread(
|
||||
project_path: String,
|
||||
) -> Result<DirectThreadSubscriptionBootstrap, String> {
|
||||
let root = Path::new(project_path.trim());
|
||||
enforce_project_permission_policy(root, "conversation.read")?;
|
||||
let thread_id = root.to_string_lossy().into_owned();
|
||||
Ok(subscribe_direct_thread(&thread_id))
|
||||
}
|
||||
|
||||
#[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,
|
||||
|
||||
@@ -2607,6 +2607,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)
|
||||
@@ -2767,6 +2768,9 @@ fn main() {
|
||||
archive_game_creator_agent_session,
|
||||
read_local_conversation,
|
||||
read_direct_project_conversation,
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user