Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ed3369a494 | |||
| fdc48fe725 | |||
| 2748468d12 | |||
| 09ad0073fe | |||
| 42b702d362 | |||
| 656c89e4b2 | |||
| 0ec1179bf1 | |||
| 2938a49cac | |||
| ae0f9376c9 | |||
| 29d0cbb4df | |||
| 721e45f01b |
+16
@@ -172,6 +172,20 @@ _Avoid_: 多步骤向导、完整规则编辑器、拖拽编辑器
|
||||
Bark Battle 平台作品闭环按契约与领域规则、后端存储/API、最小前端纵切、投影体验、收口验证的顺序推进。
|
||||
_Avoid_: mock 先行堆积、前后端各自发散、先做排行榜 UI
|
||||
|
||||
## 项目开发对话(DirectProject)
|
||||
|
||||
**项目对话历史**:
|
||||
AGC 本地项目内 Codex 原始对话条目的持久集合,是聊天展示、工具卡片和线程恢复注入的唯一持久事实源。
|
||||
_Avoid_: 会话缓存、展示态历史、按 UI 需要另存的对话副本
|
||||
|
||||
**运行态事件**:
|
||||
Thread Manager 向订阅者推送的当前回合原始事件流,只服务运行期间与短期断线恢复,不替代项目对话历史。
|
||||
_Avoid_: 进度通知、快照轮询、第二套历史
|
||||
|
||||
**聊天投影**:
|
||||
把项目对话历史条目与运行态事件转换成消息气泡和工具卡片的读取期转换;不持久化,也不构成事实源。
|
||||
_Avoid_: 投影缓存文件、已脱敏卡片库、第二套 reducer
|
||||
|
||||
## Relationships
|
||||
|
||||
- 一个 **汪汪声浪大作战** 单局包含多个 **有效声浪触发**。
|
||||
@@ -206,3 +220,5 @@ _Avoid_: mock 先行堆积、前后端各自发散、先做排行榜 UI
|
||||
- “入口闭环”曾可能只指内部 demo 或单个详情 CTA;已解析为 **正式作品入口闭环**,不新增独立专区或活动页。
|
||||
- “创作编辑”曾可能指多步骤向导或完整编辑器;已解析为 **轻配置编辑流程**,使用单页表单 + 预览卡片完成保存草稿、发布和发布后跳转作品详情。
|
||||
- “实施顺序”曾可能按 UI 或功能并行发散;已解析为契约/领域规则先行,再做后端存储/API,随后打通最小前端纵切,最后补投影体验与收口验证。
|
||||
- “回合进度事件”曾同时指 Direct turn update 与 Thread Manager 运行态事件;已解析为 AGC 项目开发对话只保留 **运行态事件**。
|
||||
- “哪些消息可显示”曾可能由后端历史分页判断;已解析为可见性判断属于 **聊天投影**,后端只按原始条目分页,前端负责跳过不可显示条目并推进分页锚点。
|
||||
|
||||
@@ -21,6 +21,7 @@ mod direct_project_history;
|
||||
mod direct_project_turn_history;
|
||||
mod direct_runtime;
|
||||
mod direct_thread_manager;
|
||||
mod direct_thread_wire;
|
||||
mod direct_tool_bridge;
|
||||
mod direct_tool_calls;
|
||||
mod direct_tools_mcp;
|
||||
@@ -55,6 +56,7 @@ 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_thread_wire::*;
|
||||
pub(crate) use direct_tool_bridge::*;
|
||||
pub(crate) use direct_tool_calls::*;
|
||||
pub(crate) use direct_tools_mcp::*;
|
||||
|
||||
@@ -564,6 +564,17 @@ enum CodexTurnEvent {
|
||||
item_id: String,
|
||||
delta: String,
|
||||
},
|
||||
/// 思考正文增量:app-server `item/reasoning/summaryTextDelta` 的明文思考文本。
|
||||
///
|
||||
/// `item/reasoning/summaryTextDelta`(core `ReasoningContentDelta`)与
|
||||
/// `item/reasoning/textDelta`(core `ReasoningRawContentDelta`)都进这条通道:前者是
|
||||
/// reasoning item 的 `summary`,后者是它的 `content`,两段文本都随 `item/completed`
|
||||
/// 落进 `project.jsonl`、此前也已经在完成时展示给用户。plan 文本与命令输出仍然只降级为
|
||||
/// 活动状态,不下发正文。
|
||||
ReasoningDelta {
|
||||
item_id: String,
|
||||
delta: String,
|
||||
},
|
||||
IntermediateText(String),
|
||||
Activity(&'static str),
|
||||
Item {
|
||||
@@ -571,7 +582,7 @@ enum CodexTurnEvent {
|
||||
params: serde_json::Value,
|
||||
},
|
||||
Request {
|
||||
event_type: &'static str,
|
||||
kind: DirectThreadRequestKind,
|
||||
params: serde_json::Value,
|
||||
},
|
||||
RawItem(serde_json::Value),
|
||||
@@ -741,23 +752,14 @@ 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_thread_event_item(
|
||||
root: &std::path::Path,
|
||||
item: &serde_json::Value,
|
||||
) -> Option<DirectThreadItem> {
|
||||
direct_thread_item_from_value(root, item, direct_tool_call_now_ms())
|
||||
}
|
||||
|
||||
fn direct_codex_command_is_game_verification(command: &str) -> bool {
|
||||
@@ -974,19 +976,21 @@ fn direct_codex_safe_activity_for_notification(method: &str) -> Option<&'static
|
||||
}
|
||||
}
|
||||
|
||||
fn direct_codex_request_event_type(method: &str) -> Option<&'static str> {
|
||||
fn direct_codex_request_event_type(method: &str) -> Option<DirectThreadRequestKind> {
|
||||
match method {
|
||||
"item/fileChange/requestApproval"
|
||||
| "item/commandExecution/requestApproval"
|
||||
| "item/permissions/requestApproval" => Some("approval.requested"),
|
||||
"item/tool/requestUserInput" | "item/mcpToolCall/requestUserInput" => Some("ask.requested"),
|
||||
| "item/permissions/requestApproval" => Some(DirectThreadRequestKind::ApprovalRequested),
|
||||
"item/tool/requestUserInput" | "item/mcpToolCall/requestUserInput" => {
|
||||
Some(DirectThreadRequestKind::AskRequested)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn direct_codex_resolution_event_type(method: &str) -> Option<&'static str> {
|
||||
fn direct_codex_resolution_event_type(method: &str) -> Option<DirectThreadRequestKind> {
|
||||
match method {
|
||||
"serverRequest/resolved" => Some("request.resolved"),
|
||||
"serverRequest/resolved" => Some(DirectThreadRequestKind::RequestResolved),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -1059,6 +1063,33 @@ fn direct_codex_notification_event(
|
||||
intermediate_text: Option<String>,
|
||||
safe_activity: Option<&'static str>,
|
||||
) -> Option<CodexTurnEvent> {
|
||||
// 思考正文走独立通道,交给 DirectProject 的运行态事件;它不因为
|
||||
// "preparing 活动" 的降级规则被丢掉,否则界面只能等 item/completed 才看到思考。
|
||||
//
|
||||
// 两条通知都下发正文,不下发活动文本:
|
||||
// - `item/reasoning/summaryTextDelta`(core `ReasoningContentDelta`)→ reasoning item 的 `summary`;
|
||||
// - `item/reasoning/textDelta`(core `ReasoningRawContentDelta`)→ reasoning item 的 `content`,
|
||||
// 正是 `project.jsonl` 里保存、并在此前 `item/completed` 已经展示给用户的同一段文本。
|
||||
// 因此这里只是把"完成时才看到"提前为"边生成边看到",没有放宽可见文本的范围;
|
||||
// 未识别的 plan 文本与命令输出仍然只降级为活动状态,不下发正文。
|
||||
if matches!(
|
||||
method,
|
||||
"item/reasoning/summaryTextDelta" | "item/reasoning/textDelta"
|
||||
) {
|
||||
return params
|
||||
.get("delta")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|delta| CodexTurnEvent::ReasoningDelta {
|
||||
item_id: params
|
||||
.get("itemId")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| "direct-missing-item".to_string()),
|
||||
delta: delta.to_string(),
|
||||
});
|
||||
}
|
||||
let (activity, intermediate_text) = match (&intermediate_text, safe_activity) {
|
||||
(Some(_), Some(activity)) if activity == "preparing" => (Some(activity), None),
|
||||
_ => (safe_activity, intermediate_text),
|
||||
@@ -2840,22 +2871,8 @@ impl CodexAppServerConnection {
|
||||
codex_app_server_text_prompt(&request)
|
||||
.map_err(platform_llm::LlmError::InvalidRequest)?
|
||||
};
|
||||
let input = if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
|
||||
if let Some(item) = direct_user_item {
|
||||
let canonical: DirectCodexUserItem = serde_json::from_value(item.clone())
|
||||
.map_err(|error| platform_llm::LlmError::InvalidRequest(error.to_string()))?;
|
||||
direct_codex_user_item_to_codex_turn_input(
|
||||
&self.inner.workspace_path,
|
||||
&canonical,
|
||||
self.inner._skill_roots.as_deref().unwrap_or_default(),
|
||||
)
|
||||
.map_err(platform_llm::LlmError::InvalidRequest)?
|
||||
} else {
|
||||
codex_app_server_turn_input(&request, &prompt, &self.inner.workspace_path).await?
|
||||
}
|
||||
} else {
|
||||
codex_app_server_turn_input(&request, &prompt, &self.inner.workspace_path).await?
|
||||
};
|
||||
let input =
|
||||
codex_app_server_turn_input(&request, &prompt, &self.inner.workspace_path).await?;
|
||||
let _direct_tool_bridge_turn_guard =
|
||||
if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
|
||||
Some(
|
||||
@@ -2940,18 +2957,7 @@ 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,
|
||||
}),
|
||||
},
|
||||
);
|
||||
append_direct_thread_event(&direct_thread_id, DirectThreadEvent::turn_started());
|
||||
}
|
||||
let mut receiver = self.register_turn(&turn_id).await;
|
||||
let mut direct_project_history = DirectProjectHistoryAccumulator::default();
|
||||
@@ -3006,12 +3012,13 @@ impl CodexAppServerConnection {
|
||||
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() }),
|
||||
},
|
||||
// 事件自足:增量自带 item 身份与正文类别(正文 / 思考),
|
||||
// 前端 reducer 不允许靠猜 itemId 的来源决定 kind。
|
||||
DirectThreadEvent::item_delta(
|
||||
item_id.clone(),
|
||||
DirectThreadDeltaKind::Message,
|
||||
delta.clone(),
|
||||
),
|
||||
);
|
||||
}
|
||||
streamed_text.push_str(&delta);
|
||||
@@ -3042,6 +3049,18 @@ impl CodexAppServerConnection {
|
||||
});
|
||||
}
|
||||
}
|
||||
Some(CodexTurnEvent::ReasoningDelta { item_id, delta }) => {
|
||||
if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
|
||||
append_direct_thread_event(
|
||||
&direct_thread_id,
|
||||
DirectThreadEvent::item_delta(
|
||||
item_id,
|
||||
DirectThreadDeltaKind::Reasoning,
|
||||
delta,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Some(CodexTurnEvent::IntermediateText(text)) => {
|
||||
if let Some(observer) = direct_observer.as_deref_mut() {
|
||||
observer(DirectCodexTurnObservation::IntermediateText(text));
|
||||
@@ -3054,6 +3073,7 @@ impl CodexAppServerConnection {
|
||||
"rawResponseItem/completed 缺少 item".to_string(),
|
||||
));
|
||||
}
|
||||
let entry_item = direct_thread_event_item(history_root, &item);
|
||||
let history_root = history_root.to_path_buf();
|
||||
let history_item = item.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
@@ -3067,19 +3087,15 @@ 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!({}),
|
||||
},
|
||||
);
|
||||
if let Some(entry_item) = entry_item {
|
||||
append_direct_thread_event(
|
||||
&direct_thread_id,
|
||||
DirectThreadEvent::item_completed(entry_item),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(CodexTurnEvent::Request { event_type, params }) => {
|
||||
Some(CodexTurnEvent::Request { kind, params }) => {
|
||||
if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
|
||||
let request_id = params
|
||||
.get("requestId")
|
||||
@@ -3089,14 +3105,7 @@ impl CodexAppServerConnection {
|
||||
.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!({})),
|
||||
},
|
||||
DirectThreadEvent::request(kind, request_id),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3207,16 +3216,14 @@ impl CodexAppServerConnection {
|
||||
&& 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),
|
||||
},
|
||||
);
|
||||
if let Some(entry_item) =
|
||||
direct_thread_event_item(history_root, item)
|
||||
{
|
||||
append_direct_thread_event(
|
||||
&direct_thread_id,
|
||||
DirectThreadEvent::item_started(entry_item),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3258,12 +3265,7 @@ impl CodexAppServerConnection {
|
||||
{
|
||||
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 }),
|
||||
},
|
||||
DirectThreadEvent::turn_completed(status.to_string()),
|
||||
);
|
||||
}
|
||||
match status {
|
||||
@@ -3958,8 +3960,8 @@ async fn read_game_creator_codex_app_server_stdout(
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let event = if let Some(event_type) = direct_codex_resolution_event_type(method) {
|
||||
CodexTurnEvent::Request { event_type, params }
|
||||
let event = if let Some(kind) = direct_codex_resolution_event_type(method) {
|
||||
CodexTurnEvent::Request { kind, 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
|
||||
@@ -4011,8 +4013,8 @@ async fn read_game_creator_codex_app_server_stdout(
|
||||
),
|
||||
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"),
|
||||
kind: direct_codex_request_event_type(method)
|
||||
.expect("request kind checked above"),
|
||||
params,
|
||||
}
|
||||
}
|
||||
@@ -4523,11 +4525,29 @@ mod tests {
|
||||
"arguments": { "path": "game/index.html", "token": "secret" },
|
||||
"result": { "content": "large output" }
|
||||
});
|
||||
assert_eq!(direct_thread_item_id(&item).as_deref(), Some("item-1"));
|
||||
// 运行态事件必须自足:载荷是脱敏原始条目,前端不需要再按 itemId 取快照。
|
||||
let projected = direct_thread_event_item(std::path::Path::new("."), &item).expect("item");
|
||||
assert_eq!(projected.item_id(), "item-1");
|
||||
let payload = serde_json::to_value(&projected).expect("payload");
|
||||
assert_eq!(
|
||||
direct_thread_item_started_payload(&item),
|
||||
serde_json::json!({ "itemType": "mcpToolCall" })
|
||||
payload.get("itemType").and_then(serde_json::Value::as_str),
|
||||
Some("mcpToolCall")
|
||||
);
|
||||
assert_eq!(
|
||||
payload.get("itemId").and_then(serde_json::Value::as_str),
|
||||
Some("item-1")
|
||||
);
|
||||
// 卡片标题 / 折叠摘要 / kind 属于前端投影:载荷里不得出现这些 UI 语义。
|
||||
assert!(payload.get("toolCall").is_none(), "{payload}");
|
||||
assert!(payload.get("title").is_none(), "{payload}");
|
||||
assert!(payload.get("summary").is_none(), "{payload}");
|
||||
assert!(payload.get("kind").is_none(), "{payload}");
|
||||
// 参数里的密钥不得随载荷下发(脱敏占位符可以保留,明文不行)。
|
||||
let arguments = payload
|
||||
.get("arguments")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or_default();
|
||||
assert!(!arguments.contains("\"secret\""), "{payload}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -5,12 +5,11 @@ mod validation;
|
||||
mod wire;
|
||||
|
||||
pub(crate) use model::{
|
||||
DirectCodexUserAttachmentReferencePart, DirectCodexUserContentPart, DirectCodexUserItem,
|
||||
DirectCodexUserMessageEnvelope, DirectCodexUserMessageItem, DirectCodexUserRole,
|
||||
DirectCodexUserRuntimeRegionPart,
|
||||
DirectCodexUserContentPart, DirectCodexUserItem, DirectCodexUserMessageEnvelope,
|
||||
DirectCodexUserMessageItem, DirectCodexUserRole, DirectCodexUserRuntimeRegionPart,
|
||||
};
|
||||
pub(crate) use validation::validate_direct_codex_user_item;
|
||||
pub(crate) use wire::{
|
||||
direct_codex_user_item_to_codex_turn_input, direct_codex_user_item_to_prompt,
|
||||
direct_codex_user_item_to_response_item, direct_codex_user_item_to_wire_input,
|
||||
direct_codex_user_item_to_prompt, direct_codex_user_item_to_response_item,
|
||||
direct_codex_user_item_to_wire_input,
|
||||
};
|
||||
|
||||
@@ -34,25 +34,8 @@ pub(crate) enum DirectCodexUserContentPart {
|
||||
InputText { text: String },
|
||||
#[serde(rename = "agc_resource_reference")]
|
||||
AgcResourceReference { resource_id: String },
|
||||
#[serde(rename = "agc_skill_reference")]
|
||||
AgcSkillReference { name: String },
|
||||
#[serde(rename = "agc_runtime_region_reference")]
|
||||
AgcRuntimeRegionReference(DirectCodexUserRuntimeRegionPart),
|
||||
/// Uploaded project attachment kept inline in canonical content.
|
||||
#[serde(rename = "agc_attachment_reference")]
|
||||
AgcAttachmentReference(DirectCodexUserAttachmentReferencePart),
|
||||
}
|
||||
|
||||
#[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 DirectCodexUserAttachmentReferencePart {
|
||||
pub(crate) name: String,
|
||||
pub(crate) media_type: String,
|
||||
#[ts(type = "number")]
|
||||
pub(crate) size: u64,
|
||||
pub(crate) local_path: String,
|
||||
pub(crate) status: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
|
||||
|
||||
+2
-28
@@ -12,7 +12,7 @@ pub(crate) const MAX_DIRECT_CODEX_REFERENCES: usize = 32;
|
||||
pub(crate) fn validate_direct_codex_user_item(
|
||||
root: &Path,
|
||||
item: &DirectCodexUserItem,
|
||||
) -> Result<GameCreationAppManifest, String> {
|
||||
) -> Result<(), String> {
|
||||
let DirectCodexUserItem::Message(message) = item;
|
||||
if !matches!(message.role, DirectCodexUserRole::User) {
|
||||
return Err("DirectProject 只接受 user message item".to_string());
|
||||
@@ -36,42 +36,16 @@ pub(crate) fn validate_direct_codex_user_item(
|
||||
reference_count = reference_count.saturating_add(1);
|
||||
validate_resource_id_and_manifest(&manifest, resource_id)?;
|
||||
}
|
||||
DirectCodexUserContentPart::AgcSkillReference { name } => {
|
||||
let name = name.trim();
|
||||
if name.is_empty()
|
||||
|| name.chars().count() > 120
|
||||
|| matches!(name, "." | "..")
|
||||
|| name.chars().any(|character| {
|
||||
character.is_control()
|
||||
|| character.is_whitespace()
|
||||
|| matches!(character, '/' | '\\' | ':' | '$')
|
||||
})
|
||||
{
|
||||
return Err("引用的 Skill 名称无效,请移除后重新选择".to_string());
|
||||
}
|
||||
}
|
||||
DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => {
|
||||
reference_count = reference_count.saturating_add(1);
|
||||
validate_runtime_region_reference(&manifest, reference)?;
|
||||
}
|
||||
DirectCodexUserContentPart::AgcAttachmentReference(reference) => {
|
||||
if reference.name.trim().is_empty() {
|
||||
return Err("附件缺少文件名".to_string());
|
||||
}
|
||||
if !reference.local_path.trim().is_empty() {
|
||||
sanitize_attachment_local_path(&reference.local_path)
|
||||
.ok_or_else(|| "附件项目路径无效".to_string())?;
|
||||
}
|
||||
if !matches!(reference.status.trim(), "imported" | "failed") {
|
||||
return Err("附件状态无效".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if reference_count > MAX_DIRECT_CODEX_REFERENCES {
|
||||
return Err(format!("一次最多引用 {MAX_DIRECT_CODEX_REFERENCES} 个素材"));
|
||||
}
|
||||
Ok(manifest)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn validate_resource_id_and_manifest(
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
use super::model::{
|
||||
DirectCodexUserContentPart, DirectCodexUserItem, DirectCodexUserRuntimeRegionPart,
|
||||
};
|
||||
use super::model::{DirectCodexUserContentPart, DirectCodexUserItem};
|
||||
use super::validation::validate_direct_codex_user_item;
|
||||
use crate::agent::{
|
||||
read_manifest_for_project, sanitize_attachment_local_path, GameCreationAppManifest,
|
||||
};
|
||||
use crate::agent::{read_manifest_for_project, sanitize_attachment_local_path};
|
||||
use serde_json::Value;
|
||||
use std::path::Path;
|
||||
|
||||
@@ -55,47 +51,6 @@ fn direct_codex_user_item_to_response_content(
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn resource_reference_summary(
|
||||
manifest: &GameCreationAppManifest,
|
||||
resource_id: &str,
|
||||
) -> Result<String, String> {
|
||||
let resource_id = resource_id.trim();
|
||||
let asset = manifest
|
||||
.assets
|
||||
.iter()
|
||||
.find(|asset| asset.id == resource_id)
|
||||
.ok_or_else(|| "引用的素材已不存在,请移除后重新选择".to_string())?;
|
||||
let path = sanitize_attachment_local_path(&asset.local_path)
|
||||
.ok_or_else(|| "引用的素材路径无效,请移除后重新选择".to_string())?;
|
||||
Ok(format!(
|
||||
"[素材引用 resourceId={resource_id};项目路径={path}]"
|
||||
))
|
||||
}
|
||||
|
||||
fn runtime_region_summary(reference: &DirectCodexUserRuntimeRegionPart) -> String {
|
||||
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
|
||||
}
|
||||
|
||||
/// 将 canonical user item 转为 app-server `turn/start.input` 可接受的文本数组。
|
||||
/// AGC 私有 part 只在这里投影为安全摘要,canonical item 本身不被修改。
|
||||
pub(crate) fn direct_codex_user_item_to_wire_input(
|
||||
@@ -110,25 +65,38 @@ pub(crate) fn direct_codex_user_item_to_wire_input(
|
||||
let text = match part {
|
||||
DirectCodexUserContentPart::InputText { text } => text.clone(),
|
||||
DirectCodexUserContentPart::AgcResourceReference { resource_id } => {
|
||||
resource_reference_summary(&manifest, resource_id)?
|
||||
}
|
||||
DirectCodexUserContentPart::AgcSkillReference { name } => {
|
||||
format!("${}", name.trim())
|
||||
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) => {
|
||||
runtime_region_summary(reference)
|
||||
}
|
||||
DirectCodexUserContentPart::AgcAttachmentReference(reference) => {
|
||||
let mut summary = format!(
|
||||
"[附件:名称={};类型={};大小={} 字节",
|
||||
reference.name.trim(),
|
||||
reference.media_type.trim(),
|
||||
reference.size
|
||||
);
|
||||
if !reference.local_path.trim().is_empty() {
|
||||
summary.push_str(&format!(";项目路径={}", reference.local_path.trim()));
|
||||
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_str(&format!(";状态={}", reference.status.trim()));
|
||||
summary.push(']');
|
||||
summary
|
||||
}
|
||||
@@ -138,66 +106,6 @@ pub(crate) fn direct_codex_user_item_to_wire_input(
|
||||
Ok(Value::Array(input))
|
||||
}
|
||||
|
||||
pub(crate) fn direct_codex_user_item_to_codex_turn_input(
|
||||
root: &Path,
|
||||
item: &DirectCodexUserItem,
|
||||
skill_roots: &[std::path::PathBuf],
|
||||
) -> Result<Value, String> {
|
||||
let manifest = validate_direct_codex_user_item(root, item)?;
|
||||
let DirectCodexUserItem::Message(message) = item;
|
||||
let mut input = Vec::with_capacity(message.content.len());
|
||||
for part in &message.content {
|
||||
match part {
|
||||
DirectCodexUserContentPart::InputText { text } => {
|
||||
input.push(serde_json::json!({ "type": "text", "text": text }));
|
||||
}
|
||||
DirectCodexUserContentPart::AgcResourceReference { resource_id } => {
|
||||
input.push(serde_json::json!({
|
||||
"type": "text",
|
||||
"text": resource_reference_summary(&manifest, resource_id)?,
|
||||
}));
|
||||
}
|
||||
DirectCodexUserContentPart::AgcSkillReference { name } => {
|
||||
let name = name.trim();
|
||||
let path = skill_roots
|
||||
.iter()
|
||||
.map(|root| root.join(name).join("SKILL.md"))
|
||||
.find(|path| path.is_file())
|
||||
.ok_or_else(|| "引用的 Skill 当前不可用,请重新选择".to_string())?;
|
||||
input.push(serde_json::json!({
|
||||
"type": "skill",
|
||||
"name": name,
|
||||
"path": path,
|
||||
}));
|
||||
}
|
||||
DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => {
|
||||
input.push(serde_json::json!({
|
||||
"type": "text",
|
||||
"text": runtime_region_summary(reference),
|
||||
}));
|
||||
}
|
||||
DirectCodexUserContentPart::AgcAttachmentReference(reference) => {
|
||||
let mut summary = format!(
|
||||
"[附件:名称={};类型={};大小={} 字节",
|
||||
reference.name.trim(),
|
||||
reference.media_type.trim(),
|
||||
reference.size
|
||||
);
|
||||
if !reference.local_path.trim().is_empty() {
|
||||
summary.push_str(&format!(";项目路径={}", reference.local_path.trim()));
|
||||
}
|
||||
summary.push_str(&format!(";状态={}", reference.status.trim()));
|
||||
summary.push(']');
|
||||
input.push(serde_json::json!({
|
||||
"type": "text",
|
||||
"text": summary,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Value::Array(input))
|
||||
}
|
||||
|
||||
pub(crate) fn direct_codex_user_item_to_prompt(
|
||||
root: &Path,
|
||||
item: &DirectCodexUserItem,
|
||||
@@ -266,26 +174,4 @@ mod tests {
|
||||
.expect_err("history item without type must fail");
|
||||
assert!(error.contains("缺少 type"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachment_parts_remain_in_canonical_order_when_projected() {
|
||||
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": "先看"},
|
||||
{"type": "agc_attachment_reference", "name": "notes.txt", "mediaType": "text/plain", "size": 4, "localPath": "assets/notes.txt", "status": "imported"}
|
||||
]
|
||||
});
|
||||
let projected = direct_codex_user_item_to_response_item(root.path(), &item)
|
||||
.expect("user response item should project");
|
||||
let content = projected["content"].as_array().expect("content array");
|
||||
assert_eq!(content.len(), 2);
|
||||
assert!(content[0]["text"].as_str().unwrap().contains("先看"));
|
||||
assert!(content[1]["text"].as_str().unwrap().contains("notes.txt"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,9 +31,10 @@ pub(crate) fn normalize_direct_client_turn_id(
|
||||
pub(crate) async fn chat_with_game_creator_direct_codex(
|
||||
project_path: String,
|
||||
prompt: String,
|
||||
user_item: DirectCodexUserItem,
|
||||
mut user_item: DirectCodexUserItem,
|
||||
creation_type: Option<String>,
|
||||
client_turn_id: Option<String>,
|
||||
attachments: Option<Vec<DirectCodexTurnAttachment>>,
|
||||
) -> Result<String, String> {
|
||||
let root = Path::new(project_path.trim());
|
||||
let turn_id = normalize_direct_client_turn_id(client_turn_id.as_deref())?;
|
||||
@@ -42,7 +43,24 @@ pub(crate) async fn chat_with_game_creator_direct_codex(
|
||||
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, &[]);
|
||||
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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -8,11 +8,9 @@
|
||||
//! 为什么不复用 `project.jsonl`:那条链路的回读只投影 `role ∈ {user, assistant}` 的
|
||||
//! 文本条目,而且会被注入 Codex 上下文。往里面塞新形状既装不下,又有污染模型上下文的风险。
|
||||
|
||||
use crate::agent::redact_secret_tokens;
|
||||
use crate::agent::sanitize_error_context;
|
||||
use super::direct_thread_wire::sanitize_detail_text;
|
||||
use crate::config::{prepare_game_creator_private_path_for_read, write_game_creator_private_file};
|
||||
use crate::project::{enforce_project_permission_policy, project_append_lock_for};
|
||||
use crate::redact_absolute_path_tokens;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
@@ -86,134 +84,6 @@ fn tool_calls_path(root: &Path) -> PathBuf {
|
||||
root.join(".agent/conversations/tool-calls.jsonl")
|
||||
}
|
||||
|
||||
/// 项目根目录之后的路径 token:分隔符统一成 `/`,返回 `(消费到的下标, 项目相对路径)`。
|
||||
fn project_relative_path_segment(value: &str, start: usize) -> (usize, String) {
|
||||
let mut index = start;
|
||||
let mut relative = String::new();
|
||||
while index < value.len() {
|
||||
let character = value[index..].chars().next().unwrap_or_default();
|
||||
if matches!(character, '/' | '\\') {
|
||||
if !relative.is_empty() {
|
||||
relative.push('/');
|
||||
}
|
||||
index += character.len_utf8();
|
||||
continue;
|
||||
}
|
||||
if character.is_whitespace()
|
||||
|| matches!(
|
||||
character,
|
||||
'\'' | '"'
|
||||
| '`'
|
||||
| ','
|
||||
| ';'
|
||||
| '|'
|
||||
| '&'
|
||||
| '('
|
||||
| ')'
|
||||
| '['
|
||||
| ']'
|
||||
| '{'
|
||||
| '}'
|
||||
| '<'
|
||||
| '>'
|
||||
| ':'
|
||||
)
|
||||
{
|
||||
break;
|
||||
}
|
||||
relative.push(character);
|
||||
index += character.len_utf8();
|
||||
}
|
||||
while relative.ends_with('/') {
|
||||
relative.pop();
|
||||
}
|
||||
(index, relative)
|
||||
}
|
||||
|
||||
/// 把项目根目录前缀换成**项目相对路径**(`<root>/game/src/x.ts` → `game/src/x.ts`)。
|
||||
///
|
||||
/// 必须排在 `redact_absolute_path_tokens` 之前:后者会把整个绝对路径抹成
|
||||
/// `<absolute-path>`,之后就再也认不出哪些路径在项目内了。
|
||||
/// Windows 上同时匹配 `\` 与 `/` 两种分隔符写法,并按大小写不敏感比较(盘符大小写会变)。
|
||||
fn relativize_project_root_paths(root: &Path, value: &str) -> String {
|
||||
let root_text = root.to_string_lossy();
|
||||
let root_text = root_text.trim_end_matches(['/', '\\']);
|
||||
if root_text.is_empty() {
|
||||
return value.to_string();
|
||||
}
|
||||
let mut needles = [
|
||||
root_text.to_string(),
|
||||
root_text.replace('\\', "/"),
|
||||
root_text.replace('/', "\\"),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|needle| needle.to_ascii_lowercase())
|
||||
.filter(|needle| !needle.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
needles.sort();
|
||||
needles.dedup();
|
||||
let lower = value.to_ascii_lowercase();
|
||||
|
||||
let mut output = String::with_capacity(value.len());
|
||||
let mut cursor = 0usize;
|
||||
while cursor < value.len() {
|
||||
let mut hit: Option<(usize, usize)> = None;
|
||||
for needle in &needles {
|
||||
let mut search = cursor;
|
||||
while let Some(relative) = lower[search..].find(needle.as_str()) {
|
||||
let start = search + relative;
|
||||
let end = start + needle.len();
|
||||
let left_is_boundary = start == 0
|
||||
|| lower[..start].chars().next_back().is_some_and(|character| {
|
||||
!character.is_alphanumeric() && character != '_' && character != '-'
|
||||
});
|
||||
if left_is_boundary && value[end..].starts_with(['/', '\\']) {
|
||||
if hit.is_none_or(|(best_start, _)| start < best_start) {
|
||||
hit = Some((start, end));
|
||||
}
|
||||
break;
|
||||
}
|
||||
search = end;
|
||||
}
|
||||
}
|
||||
let Some((start, end)) = hit else {
|
||||
break;
|
||||
};
|
||||
output.push_str(&value[cursor..start]);
|
||||
let (consumed, relative) = project_relative_path_segment(value, end);
|
||||
if relative.is_empty() {
|
||||
// 只写了项目根目录本身(没有后续路径段):按占位形状处理。
|
||||
output.push_str("<absolute-path>");
|
||||
} else {
|
||||
output.push_str(&relative);
|
||||
}
|
||||
cursor = consumed;
|
||||
}
|
||||
output.push_str(&value[cursor..]);
|
||||
output
|
||||
}
|
||||
|
||||
/// 脱敏:项目内绝对路径先归一化成项目相对路径,再依次做绝对路径、密钥前缀与
|
||||
/// 错误上下文脱敏。
|
||||
///
|
||||
/// 顺序不能反:先抹密钥会把 `sk-…` 之类的 token 换成占位符,但绝对路径里的用户名目录
|
||||
/// 仍然会留下;这里先归一化路径 token,再处理密钥。
|
||||
///
|
||||
/// 复用既有 `agent/generation/prompt_context.rs` 的脱敏组合:`sanitize_error_context`
|
||||
/// 就是 `redact_secret_tokens` + `redact_error_sensitive_assignments` +
|
||||
/// `redact_error_bearer_values` + `redact_error_config_names` 的既有组合用法,覆盖
|
||||
/// `Authorization: Bearer …`、`Cookie: …`、`api_key=…`、`client_secret=…` 这类键值凭据;
|
||||
/// 含 `--password` / `--token` / `--secret` 这类敏感 CLI 标志的行按既有 fail-closed
|
||||
/// 约定整行替换成 `[redacted sensitive context]`(与 `sanitize_agent_runtime_text` 一致)。
|
||||
///
|
||||
/// `pub(crate)`:回合流(`direct_turn_stream`)的文本段复用同一套脱敏,避免两处口径分叉。
|
||||
pub(crate) fn sanitize_detail_text(root: &Path, value: &str) -> String {
|
||||
let without_project_root = relativize_project_root_paths(root, value);
|
||||
let without_absolute = redact_absolute_path_tokens(&without_project_root);
|
||||
let without_secret = redact_secret_tokens(&without_absolute);
|
||||
sanitize_error_context(&without_secret)
|
||||
}
|
||||
|
||||
/// 按字符数截断(不切坏 UTF-8),并在真正截断时补省略号。
|
||||
fn bounded_chars(value: &str, max_chars: usize) -> String {
|
||||
if value.chars().count() <= max_chars {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::Deserialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::borrow::Cow;
|
||||
use std::collections::BTreeSet;
|
||||
@@ -121,13 +121,6 @@ struct AgcSkillManifestEntry {
|
||||
sha256: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct AgcSkillCatalogEntry {
|
||||
pub(crate) name: String,
|
||||
pub(crate) description: String,
|
||||
}
|
||||
|
||||
fn is_safe_skill_relative_path(value: &str) -> bool {
|
||||
let path = Path::new(value);
|
||||
!value.is_empty()
|
||||
@@ -241,21 +234,6 @@ pub(crate) fn agc_skill_pack_fingerprint() -> Result<String, String> {
|
||||
Ok(format!("{:x}", Sha256::digest(canonical_manifest.as_ref())))
|
||||
}
|
||||
|
||||
/// 返回当前客户端随 AGC 一起启用的内置 Skill 候选。
|
||||
///
|
||||
/// 前端不得复制审核清单;Skill 名称和描述统一从经过校验的资源 manifest 派生。
|
||||
#[tauri::command]
|
||||
pub(crate) fn list_agc_skill_catalog() -> Result<Vec<AgcSkillCatalogEntry>, String> {
|
||||
Ok(validated_skill_pack_manifest()?
|
||||
.skills
|
||||
.into_iter()
|
||||
.map(|entry| AgcSkillCatalogEntry {
|
||||
name: entry.name,
|
||||
description: entry.purpose,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub(crate) fn render_agc_skill_pack_index() -> Result<String, String> {
|
||||
let manifest = validated_skill_pack_manifest()?;
|
||||
let mut lines = vec![format!(
|
||||
@@ -350,19 +328,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skill_catalog_is_derived_from_the_validated_manifest() {
|
||||
let catalog = list_agc_skill_catalog().expect("skill catalog");
|
||||
assert_eq!(catalog.len(), AGC_SKILL_PACK_EXPECTED_NAMES.len());
|
||||
for expected_name in AGC_SKILL_PACK_EXPECTED_NAMES {
|
||||
let entry = catalog
|
||||
.iter()
|
||||
.find(|entry| entry.name == expected_name)
|
||||
.expect("expected bundled skill");
|
||||
assert!(!entry.description.trim().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skill_content_digest_is_stable_across_lf_and_crlf() {
|
||||
fn digest(bytes: &[u8]) -> String {
|
||||
|
||||
@@ -5383,10 +5383,21 @@ pub(crate) async fn read_direct_project_history_slice(
|
||||
before_item_id.as_deref(),
|
||||
limit.unwrap_or(20),
|
||||
)?;
|
||||
let first_item_id = items
|
||||
.first()
|
||||
.and_then(|item| item.get("id"))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::to_string);
|
||||
let items = direct_thread_items_from_history(root, &items, |item| {
|
||||
item.get("id")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.and_then(|id| item_timestamps.get(id).copied())
|
||||
.unwrap_or_default()
|
||||
});
|
||||
Ok(DirectThreadHistorySlice {
|
||||
items,
|
||||
has_more,
|
||||
item_timestamps,
|
||||
first_item_id,
|
||||
})
|
||||
})
|
||||
.await
|
||||
|
||||
@@ -2655,7 +2655,6 @@ fn main() {
|
||||
pick_client_extension_file,
|
||||
pick_client_extension_directory,
|
||||
list_client_extensions,
|
||||
list_agc_skill_catalog,
|
||||
import_client_extension,
|
||||
set_client_extension_enabled,
|
||||
rename_client_extension,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,102 +0,0 @@
|
||||
import { LexicalComposer } from '@lexical/react/LexicalComposer';
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
||||
import { ContentEditable } from '@lexical/react/LexicalContentEditable';
|
||||
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary';
|
||||
import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin';
|
||||
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin';
|
||||
import {
|
||||
COMMAND_PRIORITY_HIGH,
|
||||
type EditorState,
|
||||
KEY_ENTER_COMMAND,
|
||||
type Klass,
|
||||
type LexicalNode,
|
||||
} from 'lexical';
|
||||
import type { ReactElement, ReactNode, Ref } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
type RichTextInputProps = {
|
||||
namespace: string;
|
||||
nodes: Klass<LexicalNode>[];
|
||||
initialEditorState?: EditorState | null;
|
||||
contentEditable?: ReactElement<typeof ContentEditable>;
|
||||
placeholder?: ReactElement;
|
||||
containerClassName?: string;
|
||||
containerRef?: Ref<HTMLDivElement>;
|
||||
disabled?: boolean;
|
||||
onChange?: (editorState: EditorState) => void;
|
||||
onEnter?: () => void;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
function SubmitOnEnter({ onEnter }: { onEnter?: () => void }) {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
|
||||
useEffect(() => {
|
||||
if (!onEnter) return undefined;
|
||||
return editor.registerCommand(
|
||||
KEY_ENTER_COMMAND,
|
||||
(event) => {
|
||||
if (!event || event.shiftKey || event.isComposing) return false;
|
||||
event.preventDefault();
|
||||
onEnter();
|
||||
return true;
|
||||
},
|
||||
COMMAND_PRIORITY_HIGH,
|
||||
);
|
||||
}, [editor, onEnter]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function SetEditorEditable({ disabled }: { disabled: boolean }) {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
|
||||
useEffect(() => {
|
||||
editor.setEditable(!disabled);
|
||||
}, [disabled, editor]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function RichTextInput({
|
||||
namespace,
|
||||
nodes,
|
||||
initialEditorState,
|
||||
contentEditable = <ContentEditable />,
|
||||
placeholder,
|
||||
containerClassName,
|
||||
containerRef,
|
||||
disabled = false,
|
||||
onChange,
|
||||
onEnter,
|
||||
children,
|
||||
}: RichTextInputProps) {
|
||||
return (
|
||||
<LexicalComposer
|
||||
initialConfig={{
|
||||
namespace,
|
||||
nodes,
|
||||
editorState: initialEditorState ?? undefined,
|
||||
onError: (error) => {
|
||||
throw error;
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={containerClassName}
|
||||
data-disabled={disabled ? 'true' : undefined}
|
||||
>
|
||||
<RichTextPlugin
|
||||
contentEditable={contentEditable}
|
||||
placeholder={placeholder}
|
||||
ErrorBoundary={LexicalErrorBoundary}
|
||||
/>
|
||||
{children}
|
||||
<SetEditorEditable disabled={disabled} />
|
||||
<SubmitOnEnter onEnter={onEnter} />
|
||||
{onChange ? <OnChangePlugin onChange={onChange} /> : null}
|
||||
</div>
|
||||
</LexicalComposer>
|
||||
);
|
||||
}
|
||||
@@ -79,7 +79,7 @@ import {
|
||||
ResourceReferenceInput,
|
||||
type ResourceReferenceInputHandle,
|
||||
} from './ResourceReferenceInput';
|
||||
import type { ChatComposerDraft } from './resourceReferences';
|
||||
import type { ChatComposerDraft, ChatReference } from './resourceReferences';
|
||||
import { ToolCallGroup } from './ToolCallGroup';
|
||||
import {
|
||||
formatClockTime,
|
||||
@@ -242,6 +242,8 @@ type ProjectSupervisorViewProps = RuntimePanelProps & {
|
||||
attachments?: DirectCodexTurnAttachment[];
|
||||
/** 上传/校验附件的提示文案(失败与成功都用它,空串不渲染)。 */
|
||||
attachmentNotice?: string;
|
||||
chatInput: string;
|
||||
chatReferences: ChatReference[];
|
||||
chatProjectAssets: import('../../../../../packages/shared/src/contracts/gameCreationApp').GameCreationAppAssetManifestEntry[];
|
||||
composerRef?: RefObject<ResourceReferenceInputHandle | null>;
|
||||
directCodex?: boolean;
|
||||
@@ -325,6 +327,8 @@ export function ProjectSupervisorView({
|
||||
activeVersionId = null,
|
||||
attachments = [],
|
||||
attachmentNotice = '',
|
||||
chatInput,
|
||||
chatReferences,
|
||||
chatProjectAssets,
|
||||
composerRef,
|
||||
directCodex = false,
|
||||
@@ -912,6 +916,8 @@ export function ProjectSupervisorView({
|
||||
Boolean(designView?.session.pendingClarification)
|
||||
}
|
||||
rows={3}
|
||||
value={chatInput}
|
||||
references={chatReferences}
|
||||
showTriggerButton={!directCodex}
|
||||
placeholder={
|
||||
directCodex
|
||||
|
||||
+7
-1
@@ -55,7 +55,7 @@ import {
|
||||
ResourceReferenceInput,
|
||||
type ResourceReferenceInputHandle,
|
||||
} from './ResourceReferenceInput';
|
||||
import type { ChatComposerDraft } from './resourceReferences';
|
||||
import type { ChatComposerDraft, ChatReference } from './resourceReferences';
|
||||
|
||||
type ProjectWorkspaceChatPaneProps = {
|
||||
activeVersionId?: string | null;
|
||||
@@ -65,6 +65,8 @@ type ProjectWorkspaceChatPaneProps = {
|
||||
cancelProjectCreateInNonEmptyFolder: () => void;
|
||||
cancelUiCommandConfirmation: () => void;
|
||||
chatAgentBusy: boolean;
|
||||
chatInput: string;
|
||||
chatReferences: ChatReference[];
|
||||
chatProjectAssets: GameCreationAppAssetManifestEntry[];
|
||||
composerRef?: Ref<ResourceReferenceInputHandle>;
|
||||
chatInputRef: RefObject<HTMLDivElement | null>;
|
||||
@@ -213,6 +215,8 @@ export function ProjectWorkspaceChatPane({
|
||||
cancelProjectCreateInNonEmptyFolder,
|
||||
cancelUiCommandConfirmation,
|
||||
chatAgentBusy,
|
||||
chatInput,
|
||||
chatReferences,
|
||||
chatProjectAssets,
|
||||
composerRef,
|
||||
chatInputRef,
|
||||
@@ -958,6 +962,8 @@ export function ProjectWorkspaceChatPane({
|
||||
projectPath={projectPath}
|
||||
disabled={chatAgentBusy || projectSupervisorNeedsUserInput}
|
||||
multiline={false}
|
||||
value={chatInput}
|
||||
references={chatReferences}
|
||||
placeholder="例如:像素风横版动作小游戏,或输入 @ 选择资源"
|
||||
onChange={onChatComposerChange}
|
||||
/>
|
||||
|
||||
+8
-19
@@ -4,16 +4,6 @@ import { X } from 'lucide-react';
|
||||
|
||||
import type { ChatReference } from './resourceReferences';
|
||||
|
||||
function chipTitle(reference: ChatReference) {
|
||||
if (reference.type === 'resource') {
|
||||
return `${reference.label} · ${reference.kind}`;
|
||||
}
|
||||
if (reference.type === 'skill') {
|
||||
return `${reference.name} · Skill`;
|
||||
}
|
||||
return `${reference.label} · 运行区域`;
|
||||
}
|
||||
|
||||
export function ResourceReferenceChip({
|
||||
reference,
|
||||
nodeKey,
|
||||
@@ -31,19 +21,18 @@ export function ResourceReferenceChip({
|
||||
data-runtime-region-reference={
|
||||
reference.type === 'runtime-region' ? 'true' : undefined
|
||||
}
|
||||
data-skill-reference-name={
|
||||
reference.type === 'skill' ? reference.name : undefined
|
||||
}
|
||||
contentEditable={false}
|
||||
title={chipTitle(reference)}
|
||||
title={
|
||||
reference.type === 'resource'
|
||||
? `${reference.label} · ${reference.kind}`
|
||||
: `${reference.label} · 运行区域`
|
||||
}
|
||||
>
|
||||
<span aria-hidden="true">{reference.type === 'skill' ? '$' : '@'}</span>
|
||||
<span className="resource-reference-chip-label">
|
||||
{reference.type === 'skill' ? reference.name : reference.label}
|
||||
</span>
|
||||
<span aria-hidden="true">@</span>
|
||||
<span className="resource-reference-chip-label">{reference.label}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`移除引用 ${reference.type === 'skill' ? reference.name : reference.label}`}
|
||||
aria-label={`移除引用 ${reference.label}`}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => {
|
||||
editor.update(() => {
|
||||
|
||||
+236
-427
File diff suppressed because it is too large
Load Diff
+7
-1
@@ -33,7 +33,7 @@ import {
|
||||
ResourceReferenceInput,
|
||||
type ResourceReferenceInputHandle,
|
||||
} from './ResourceReferenceInput';
|
||||
import type { ChatComposerDraft } from './resourceReferences';
|
||||
import type { ChatComposerDraft, ChatReference } from './resourceReferences';
|
||||
|
||||
type RuntimeControlProps = ComponentProps<
|
||||
typeof ProjectSupervisorRuntimeControls
|
||||
@@ -44,6 +44,8 @@ const CHAT_SCROLL_BOTTOM_THRESHOLD = 24;
|
||||
type SupervisorChatOnlyViewProps = {
|
||||
activeVersionId?: string | null;
|
||||
chatAgentBusy: boolean;
|
||||
chatInput: string;
|
||||
chatReferences: ChatReference[];
|
||||
chatProjectAssets: import('../../../../../packages/shared/src/contracts/gameCreationApp').GameCreationAppAssetManifestEntry[];
|
||||
composerRef?: Ref<ResourceReferenceInputHandle>;
|
||||
directCodex?: boolean;
|
||||
@@ -80,6 +82,8 @@ type SupervisorChatOnlyViewProps = {
|
||||
export function SupervisorChatOnlyView({
|
||||
activeVersionId = null,
|
||||
chatAgentBusy,
|
||||
chatInput,
|
||||
chatReferences,
|
||||
chatProjectAssets,
|
||||
composerRef,
|
||||
directCodex = false,
|
||||
@@ -324,6 +328,8 @@ export function SupervisorChatOnlyView({
|
||||
projectPath={projectPath}
|
||||
disabled={chatAgentBusy || needsUserInput}
|
||||
rows={3}
|
||||
value={chatInput}
|
||||
references={chatReferences}
|
||||
placeholder={
|
||||
directCodex ? '描述你的想法' : '给项目总控 Agent 发消息'
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
import { useEffect, useId, useState } from 'react';
|
||||
|
||||
import { AgentMessageContent } from '../../../../../packages/shared/src/components/AgentMessageContent';
|
||||
import type { GameCreatorDirectToolCall } from '../../app/types';
|
||||
import type { DirectChatToolCard } from './directThreadChat';
|
||||
import {
|
||||
formatToolCallDuration,
|
||||
formatTurnDuration,
|
||||
@@ -38,7 +38,7 @@ export function ToolCallGroup({
|
||||
active = false,
|
||||
className,
|
||||
}: {
|
||||
calls: GameCreatorDirectToolCall[];
|
||||
calls: DirectChatToolCard[];
|
||||
/** 同一回合用户消息的 `updatedAt`;拿不到就传 0,只显示结束时间。 */
|
||||
userSentAt?: number | null;
|
||||
/**
|
||||
@@ -169,7 +169,7 @@ function ToolCallRow({
|
||||
call,
|
||||
active,
|
||||
}: {
|
||||
call: GameCreatorDirectToolCall;
|
||||
call: DirectChatToolCard;
|
||||
active: boolean;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
@@ -4,32 +4,36 @@
|
||||
* 回合运行中用户再次发送时,消息进入 FIFO 队列而不是被丢弃;当前回合结束后按入队顺序
|
||||
* 依次发出。队列项能在输入盒上方单独取消。这里只放与 React 无关的纯逻辑,便于单测。
|
||||
*/
|
||||
import type { DirectCodexUserItem } from './generated';
|
||||
import { directCodexContentToPromptText } from './resourceReferences';
|
||||
import type { DirectCodexTurnAttachment } from '../app-shell/directCodexTurnAttachments';
|
||||
import type { DirectCodexUserContentPart } from './generated';
|
||||
import type { ChatReference } from './resourceReferences';
|
||||
|
||||
/** 队列上限:满了以后拒绝入队并给出可读提示,而不是静默丢消息。 */
|
||||
export const MAX_QUEUED_CHAT_TURNS = 5;
|
||||
|
||||
export type QueuedChatTurn = {
|
||||
id: string;
|
||||
clientTurnId: string;
|
||||
userItem: DirectCodexUserItem;
|
||||
prompt: string;
|
||||
attachments: DirectCodexTurnAttachment[];
|
||||
references: ChatReference[];
|
||||
content?: DirectCodexUserContentPart[];
|
||||
createdAt: number;
|
||||
};
|
||||
|
||||
export function createQueuedChatTurn(input: {
|
||||
id: string;
|
||||
clientTurnId: string;
|
||||
userItem: DirectCodexUserItem;
|
||||
prompt: string;
|
||||
attachments?: readonly DirectCodexTurnAttachment[];
|
||||
references?: readonly ChatReference[];
|
||||
content?: readonly DirectCodexUserContentPart[];
|
||||
createdAt: number;
|
||||
}): QueuedChatTurn {
|
||||
return {
|
||||
id: input.id,
|
||||
clientTurnId: input.clientTurnId,
|
||||
userItem: {
|
||||
...input.userItem,
|
||||
content: [...input.userItem.content],
|
||||
},
|
||||
prompt: input.prompt,
|
||||
attachments: [...(input.attachments ?? [])],
|
||||
references: [...(input.references ?? [])],
|
||||
content: [...(input.content ?? [])],
|
||||
createdAt: input.createdAt,
|
||||
};
|
||||
}
|
||||
@@ -75,19 +79,14 @@ export function chatQueueFullNotice(): string {
|
||||
|
||||
/** 队列 chip 上显示的文字:单行、有长度上限。 */
|
||||
export function queuedChatTurnLabel(turn: QueuedChatTurn): string {
|
||||
const text = directCodexContentToPromptText(turn.userItem.content)
|
||||
.trim()
|
||||
.replace(/\s+/gu, ' ');
|
||||
const text = turn.prompt.trim().replace(/\s+/gu, ' ');
|
||||
if (text) {
|
||||
return text.length > 24 ? `${text.slice(0, 24)}…` : text;
|
||||
}
|
||||
const attachment = turn.userItem.content.find(
|
||||
(part) => part.type === 'agc_attachment_reference',
|
||||
);
|
||||
if (attachment?.type === 'agc_attachment_reference') {
|
||||
return `附件 · ${attachment.name || '未命名'}`;
|
||||
if (turn.attachments.length > 0) {
|
||||
return `附件 · ${turn.attachments[0]?.name ?? '未命名'}`;
|
||||
}
|
||||
if (turn.userItem.content.some((part) => part.type !== 'input_text')) {
|
||||
if (turn.references.length > 0) {
|
||||
return '素材引用';
|
||||
}
|
||||
return '未命名消息';
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { resolveTauriInvoke } from '../../app/tauri';
|
||||
import type { DirectCodexUserContentPart } from './generated';
|
||||
import {
|
||||
type ChatComposerDraft,
|
||||
chatReferenceListKey,
|
||||
} from './resourceReferences';
|
||||
|
||||
/**
|
||||
* 「不再提醒」偏好存本机 localStorage,不进 manifest、不进后端。
|
||||
@@ -58,10 +61,8 @@ export function writeChatPromptPolishReminderDisabled(disabled: boolean) {
|
||||
}
|
||||
|
||||
/** 草稿指纹:用于判断「本轮草稿」是否已经被润色或确认过。 */
|
||||
export function chatPromptDraftKey(
|
||||
content: readonly DirectCodexUserContentPart[],
|
||||
) {
|
||||
return JSON.stringify(content);
|
||||
export function chatPromptDraftKey(draft: ChatComposerDraft) {
|
||||
return `${draft.text}\u0000${chatReferenceListKey(draft.references)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -72,27 +73,25 @@ export function chatPromptDraftKey(
|
||||
* 4. 草稿不是以 `/` 开头的命令 —— 命令走直通路径,不参与提醒。
|
||||
*/
|
||||
export function shouldRemindChatPromptPolish({
|
||||
content,
|
||||
prompt,
|
||||
draft,
|
||||
acknowledgedDraftKey,
|
||||
reminderDisabled,
|
||||
}: {
|
||||
content: readonly DirectCodexUserContentPart[];
|
||||
prompt: string;
|
||||
draft: ChatComposerDraft;
|
||||
acknowledgedDraftKey: string | null;
|
||||
reminderDisabled: boolean;
|
||||
}) {
|
||||
if (reminderDisabled) {
|
||||
return false;
|
||||
}
|
||||
const text = prompt.trim();
|
||||
const text = draft.text.trim();
|
||||
if (text.length < CHAT_PROMPT_POLISH_REMINDER_MIN_TEXT_LENGTH) {
|
||||
return false;
|
||||
}
|
||||
if (text.startsWith('/')) {
|
||||
return false;
|
||||
}
|
||||
return chatPromptDraftKey(content) !== acknowledgedDraftKey;
|
||||
return chatPromptDraftKey(draft) !== acknowledgedDraftKey;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* DirectProject 聊天 reducer:把运行态事件与历史切片归并成同一份聊天条目。
|
||||
*
|
||||
* 事实源只有一个——项目对话历史;运行态事件只负责"当前回合"。顺序 = 历史文件顺序 +
|
||||
* 运行态独有条目。这里不做可见性判断(那是投影的事),也不认任何回合身份:DirectProject
|
||||
* 同一时刻只有一个回合在跑,`turn.started` / `turn.completed` 只切换"是否还在跑"这一个布尔。
|
||||
*/
|
||||
|
||||
import type { GameCreatorDirectToolCall } from '../../app/types';
|
||||
import type {
|
||||
DirectThreadConsumeResult,
|
||||
DirectThreadEvent,
|
||||
DirectThreadHistorySlice,
|
||||
DirectThreadItem,
|
||||
DirectThreadSubscriptionBootstrap,
|
||||
} from './directThreadEvents';
|
||||
import { projectDirectThreadItem } from './directThreadItemProjection';
|
||||
|
||||
export type DirectChatEntryKind = 'message' | 'reasoning' | 'tool';
|
||||
|
||||
/** 聊天卡片里的工具形状:持久化卡片去掉回合身份(Rust 侧已经不下发 turn id)。 */
|
||||
export type DirectChatToolCard = Omit<GameCreatorDirectToolCall, 'turnId'>;
|
||||
|
||||
/** 聊天视图里的一条条目;运行态事件与历史切片共用的唯一形状。 */
|
||||
export type DirectChatEntry = {
|
||||
itemId: string;
|
||||
kind: DirectChatEntryKind;
|
||||
role?: 'user' | 'assistant' | null;
|
||||
text?: string | null;
|
||||
toolCall?: DirectChatToolCard | null;
|
||||
at?: number;
|
||||
};
|
||||
|
||||
export type DirectThreadChatState = {
|
||||
subscriptionId: string | null;
|
||||
/** 首屏历史锚点:`subscribe` 给出的最后一条完成条目 id。 */
|
||||
lastCompletedItemId: string | null;
|
||||
/** 最新回合是否还在跑;只由生命周期事件的先后决定。 */
|
||||
turnRunning: boolean;
|
||||
/** 历史切片条目,保持文件顺序。 */
|
||||
history: DirectChatEntry[];
|
||||
/** 当前回合的运行态条目,保持到达顺序;回合结束即并入历史并清空。 */
|
||||
live: DirectChatEntry[];
|
||||
};
|
||||
|
||||
export function emptyDirectThreadChatState(): DirectThreadChatState {
|
||||
return {
|
||||
subscriptionId: null,
|
||||
lastCompletedItemId: null,
|
||||
turnRunning: false,
|
||||
history: [],
|
||||
live: [],
|
||||
};
|
||||
}
|
||||
|
||||
function longerText(
|
||||
left: string | null | undefined,
|
||||
right: string | null | undefined,
|
||||
): string | null {
|
||||
const a = typeof left === 'string' ? left : '';
|
||||
const b = typeof right === 'string' ? right : '';
|
||||
// 正文只增不减:增量往同一段落追加,完成快照可能比累计更长(漏过几条 delta)。
|
||||
return b.length > a.length ? b : a;
|
||||
}
|
||||
|
||||
function mergeToolStatus(
|
||||
left: DirectChatToolCard['status'] | null | undefined,
|
||||
right: DirectChatToolCard['status'] | null | undefined,
|
||||
): DirectChatToolCard['status'] {
|
||||
// 只有终态才算数:先到的 `running` 允许被后到的完成 / 失败覆盖,反过来不行。
|
||||
if (left === 'running' || !left) return right ?? left ?? 'running';
|
||||
return left;
|
||||
}
|
||||
|
||||
function mergeToolCard(
|
||||
left: DirectChatToolCard | null,
|
||||
right: DirectChatToolCard | null,
|
||||
): DirectChatToolCard | null {
|
||||
if (!left) return right;
|
||||
if (!right) return left;
|
||||
return {
|
||||
...left,
|
||||
kind: left.kind && left.kind !== 'other' ? left.kind : right.kind,
|
||||
title: left.title?.trim() ? left.title : right.title,
|
||||
summary: left.summary?.trim() ? left.summary : right.summary,
|
||||
status: mergeToolStatus(left.status, right.status),
|
||||
detail: {
|
||||
command: left.detail.command ?? right.detail.command,
|
||||
output: left.detail.output ?? right.detail.output,
|
||||
changes: left.detail.changes?.length
|
||||
? left.detail.changes
|
||||
: right.detail.changes,
|
||||
},
|
||||
startedAt: left.startedAt > 0 ? left.startedAt : right.startedAt,
|
||||
updatedAt: Math.max(left.updatedAt, right.updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 先到的快照赢,后到的只补空字段。
|
||||
*
|
||||
* 三个例外只有"后到信息一定更全"时才成立:正文取更长的一份、工具状态允许从 `running`
|
||||
* 升级到终态、`updatedAt` 取较新的时间。其余字段一律先到先用,后到的空值不得抹掉它。
|
||||
*/
|
||||
export function mergeDirectChatEntry(
|
||||
existing: DirectChatEntry,
|
||||
incoming: DirectChatEntry,
|
||||
): DirectChatEntry {
|
||||
return {
|
||||
itemId: existing.itemId || incoming.itemId,
|
||||
kind:
|
||||
existing.kind === 'tool' || incoming.kind === 'tool'
|
||||
? 'tool'
|
||||
: existing.kind,
|
||||
role: existing.role ?? incoming.role ?? null,
|
||||
text: longerText(existing.text, incoming.text),
|
||||
toolCall: mergeToolCard(
|
||||
existing.toolCall ?? null,
|
||||
incoming.toolCall ?? null,
|
||||
),
|
||||
at: existing.at || incoming.at,
|
||||
};
|
||||
}
|
||||
|
||||
function upsertLiveEntry(
|
||||
state: DirectThreadChatState,
|
||||
entry: DirectChatEntry,
|
||||
): DirectThreadChatState {
|
||||
const index = state.live.findIndex(
|
||||
(existing) => existing.itemId === entry.itemId,
|
||||
);
|
||||
if (index < 0) {
|
||||
return { ...state, live: [...state.live, entry] };
|
||||
}
|
||||
const existing = state.live[index];
|
||||
if (!existing) {
|
||||
return { ...state, live: [...state.live, entry] };
|
||||
}
|
||||
const live = [...state.live];
|
||||
live[index] = mergeDirectChatEntry(existing, entry);
|
||||
return { ...state, live };
|
||||
}
|
||||
|
||||
function appendLiveText(
|
||||
state: DirectThreadChatState,
|
||||
event: Extract<DirectThreadEvent, { type: 'item.delta' }>,
|
||||
): DirectThreadChatState {
|
||||
const itemId = event.itemId.trim();
|
||||
if (!itemId || !event.delta) return state;
|
||||
const reasoning = event.kind === 'reasoning';
|
||||
const existing = state.live.find((entry) => entry.itemId === itemId);
|
||||
return upsertLiveEntry(state, {
|
||||
itemId,
|
||||
kind: reasoning ? 'reasoning' : 'message',
|
||||
role: reasoning ? null : 'assistant',
|
||||
text: `${existing?.text ?? ''}${event.delta}`,
|
||||
});
|
||||
}
|
||||
|
||||
export function reduceDirectThreadEvent(
|
||||
state: DirectThreadChatState,
|
||||
event: DirectThreadEvent,
|
||||
): DirectThreadChatState {
|
||||
switch (event.type) {
|
||||
case 'turn.started':
|
||||
return { ...state, turnRunning: true };
|
||||
case 'turn.completed':
|
||||
// 回合结束:条目已经落盘,运行态并入历史后清空,避免同一条目渲染两次。
|
||||
return {
|
||||
...state,
|
||||
turnRunning: false,
|
||||
history: mergeHistoryEntries(state.history, state.live),
|
||||
live: [],
|
||||
};
|
||||
case 'item.delta':
|
||||
return appendLiveText(state, event);
|
||||
case 'item.started':
|
||||
case 'item.completed': {
|
||||
const entry = projectDirectThreadItem(event.item);
|
||||
return entry ? upsertLiveEntry(state, entry) : state;
|
||||
}
|
||||
case 'request':
|
||||
// 审批 / 提问只影响面板交互,不并入聊天条目。
|
||||
return state;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export function reduceDirectThreadEvents(
|
||||
state: DirectThreadChatState,
|
||||
events: readonly DirectThreadEvent[],
|
||||
): DirectThreadChatState {
|
||||
return events.reduce(reduceDirectThreadEvent, state);
|
||||
}
|
||||
|
||||
/**
|
||||
* bootstrap 是运行态的唯一权威:游标已在队尾,返回的事件就是此刻要处理的事件。
|
||||
*
|
||||
* 历史窗口保留:bootstrap 不重新回读历史切片,那是 `lastCompletedItemId` 的职责。
|
||||
*/
|
||||
export function resolveDirectThreadBootstrap(
|
||||
state: DirectThreadChatState,
|
||||
bootstrap: DirectThreadSubscriptionBootstrap,
|
||||
): DirectThreadChatState {
|
||||
return reduceDirectThreadEvents(
|
||||
{
|
||||
...state,
|
||||
subscriptionId: bootstrap.subscriptionId,
|
||||
lastCompletedItemId: bootstrap.lastCompletedItemId ?? null,
|
||||
},
|
||||
bootstrap.events,
|
||||
);
|
||||
}
|
||||
|
||||
/** 事件顺序 = 游标顺序;调用方只需要把 `consume` 的结果喂进来。 */
|
||||
export function applyDirectThreadConsumeResult(
|
||||
state: DirectThreadChatState,
|
||||
result: DirectThreadConsumeResult,
|
||||
): DirectThreadChatState {
|
||||
return reduceDirectThreadEvents(state, result.events);
|
||||
}
|
||||
|
||||
/** 同一身份的条目合并,先到者在前:历史在前、运行态在后,运行态只补空。 */
|
||||
export function mergeHistoryEntries(
|
||||
leading: readonly DirectChatEntry[],
|
||||
trailing: readonly DirectChatEntry[],
|
||||
): DirectChatEntry[] {
|
||||
const byId = new Map<string, number>();
|
||||
const entries: DirectChatEntry[] = [];
|
||||
for (const entry of [...leading, ...trailing]) {
|
||||
const index = byId.get(entry.itemId);
|
||||
if (index === undefined) {
|
||||
byId.set(entry.itemId, entries.length);
|
||||
entries.push(entry);
|
||||
continue;
|
||||
}
|
||||
const existing = entries[index];
|
||||
if (existing) entries[index] = mergeDirectChatEntry(existing, entry);
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* 历史切片并入:切片是脱敏原始条目,投影规则与运行态完全同一份。
|
||||
*
|
||||
* 同一调用的调用与输出在这里按身份合并成一张卡片,而不是在 Rust 侧合并。
|
||||
*/
|
||||
export function mergeDirectHistoryItems(
|
||||
state: DirectThreadChatState,
|
||||
items: readonly DirectThreadItem[],
|
||||
): DirectThreadChatState {
|
||||
const entries = items
|
||||
.map((item) => projectDirectThreadItem(item))
|
||||
.filter((entry): entry is DirectChatEntry => Boolean(entry));
|
||||
return { ...state, history: mergeHistoryEntries(entries, state.history) };
|
||||
}
|
||||
|
||||
export function mergeDirectThreadHistorySlice(
|
||||
state: DirectThreadChatState,
|
||||
slice: DirectThreadHistorySlice,
|
||||
): DirectThreadChatState {
|
||||
return mergeDirectHistoryItems(state, slice.items);
|
||||
}
|
||||
|
||||
/** 聊天投影输入:历史顺序 + 运行态覆盖;运行态独有条目排在最后。 */
|
||||
export function selectDirectChatEntries(
|
||||
state: DirectThreadChatState,
|
||||
): DirectChatEntry[] {
|
||||
return mergeHistoryEntries(state.history, state.live);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user