Compare commits
100 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d1fa973d25 | |||
| 9fa05eccaf | |||
| 709ef6256c | |||
| 37d45d2de8 | |||
| 6b13d1d613 | |||
| d5eca8cce6 | |||
| 2e78cbf152 | |||
| 840baf208f | |||
| d8af0fa882 | |||
| faed0ec251 | |||
| 6e63a118e0 | |||
| 4ae5ab14fa | |||
| b73a329b2c | |||
| 06004bfeea | |||
| ecea7b4ad3 | |||
| b1e82d64cb | |||
| b7622f4a24 | |||
| cb840c6e8c | |||
| 0a9cdf6395 | |||
| e382d23892 | |||
| 37ae7508b8 | |||
| 1ae6c6cd06 | |||
| 459f8a60e5 | |||
| 63c495dab0 | |||
| b37d91882c | |||
| dd339638cc | |||
| 0caf998227 | |||
| 6eed6bd13f | |||
| b543a060b7 | |||
| 9993b2feb5 | |||
| d348cf9064 | |||
| e640219243 | |||
| da2ad83c3b | |||
| ad19c9475e | |||
| 975a2e577c | |||
| 1b40f030e7 | |||
| 098693636a | |||
| e17af3008e | |||
| 64bd006981 | |||
| 6a4cb2bc18 | |||
| 1b3a3b003f | |||
| ff8e517c8e | |||
| d874a13a92 | |||
| 5eba53a5cc | |||
| 33ddb5de27 | |||
| bf8f96c115 | |||
| e469831543 | |||
| 869abad879 | |||
| 280a2dc5d1 | |||
| 3c6e084a41 | |||
| ed3369a494 | |||
| fdc48fe725 | |||
| 2748468d12 | |||
| 09ad0073fe | |||
| 42b702d362 | |||
| edfd1f9143 | |||
| 423f2d7184 | |||
| 656c89e4b2 | |||
| aa9076c95c | |||
| 0ec1179bf1 | |||
| 2938a49cac | |||
| ae0f9376c9 | |||
| 29d0cbb4df | |||
| 41366dd71d | |||
| 721e45f01b | |||
| f4b496caca | |||
| fc0fd846f9 | |||
| fa99d9ad62 | |||
| 64a52005f8 | |||
| 645d806fed | |||
| 0b9d16e8f1 | |||
| d5c4e0e780 | |||
| 30e27abc60 | |||
| b607501916 | |||
| 46e3cd9744 | |||
| 5fb3db662b | |||
| 87b798322e | |||
| ec286b3480 | |||
| 35733f0e33 | |||
| 3c7b02b9f8 | |||
| f2030a616f | |||
| b3b5d77990 | |||
| 1da106af7d | |||
| cd2edd6966 | |||
| 22e830ff93 | |||
| c266ae7b50 | |||
| a543b75cf7 | |||
| f213987f9a | |||
| 2c623bb577 | |||
| 3353906e6f | |||
| 17716347e2 | |||
| 9ad66a67a3 | |||
| ba3aa3ccdb | |||
| 2b38eaafba | |||
| 7d5b9071e7 | |||
| 9e83f1d88c | |||
| 7e35d7c344 | |||
| 5ec40c8b83 | |||
| 948a80fc49 | |||
| dfd6fadedf |
@@ -1133,7 +1133,6 @@ fn direct_codex_thread_delta_event(
|
|||||||
) -> DirectThreadEvent {
|
) -> DirectThreadEvent {
|
||||||
DirectThreadEvent::item_delta(item_id, kind, direct_thread_delta_text(root, delta))
|
DirectThreadEvent::item_delta(item_id, kind, direct_thread_delta_text(root, delta))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 通知 → 回合事件的唯一分类函数:运行态读取器与单测共用这一份。
|
/// 通知 → 回合事件的唯一分类函数:运行态读取器与单测共用这一份。
|
||||||
///
|
///
|
||||||
/// 读取器只负责"必须有 turnId 才处理"的前置条件与节流(活动 / 正文),分类不在这里之外
|
/// 读取器只负责"必须有 turnId 才处理"的前置条件与节流(活动 / 正文),分类不在这里之外
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
//! 有项目路径或导入状态时输出路径映射;否则保持首页元数据文案。不灌正文。
|
//! 有项目路径或导入状态时输出路径映射;否则保持首页元数据文案。不灌正文。
|
||||||
|
|
||||||
pub(crate) const MAX_DIRECT_CODEX_ATTACHMENTS: usize = 8;
|
pub(crate) const MAX_DIRECT_CODEX_ATTACHMENTS: usize = 8;
|
||||||
const MAX_DIRECT_CODEX_ATTACHMENT_NAME_CHARS: usize = 160;
|
pub(crate) const MAX_DIRECT_CODEX_ATTACHMENT_NAME_CHARS: usize = 160;
|
||||||
const MAX_DIRECT_CODEX_ATTACHMENT_MEDIA_TYPE_CHARS: usize = 96;
|
pub(crate) const MAX_DIRECT_CODEX_ATTACHMENT_MEDIA_TYPE_CHARS: usize = 96;
|
||||||
const MAX_DIRECT_CODEX_ATTACHMENT_LOCAL_PATH_CHARS: usize = 512;
|
pub(crate) const MAX_DIRECT_CODEX_ATTACHMENT_LOCAL_PATH_CHARS: usize = 512;
|
||||||
|
|
||||||
const HOME_ATTACHMENT_HEADER: &str =
|
const HOME_ATTACHMENT_HEADER: &str =
|
||||||
"[首页附件说明:当前尚未打开项目,以下仅为附件元数据,附件内容尚不可读取]";
|
"[首页附件说明:当前尚未打开项目,以下仅为附件元数据,附件内容尚不可读取]";
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ mod validation;
|
|||||||
mod wire;
|
mod wire;
|
||||||
|
|
||||||
pub(crate) use model::{
|
pub(crate) use model::{
|
||||||
DirectCodexUserContentPart, DirectCodexUserItem, DirectCodexUserMessageItem,
|
DirectCodexUserAttachmentReferencePart, DirectCodexUserContentPart, DirectCodexUserItem,
|
||||||
DirectCodexUserRole, DirectCodexUserRuntimeRegionPart,
|
DirectCodexUserMessageItem, DirectCodexUserRole, DirectCodexUserRuntimeRegionPart,
|
||||||
};
|
};
|
||||||
pub(crate) use validation::validate_direct_codex_user_item;
|
pub(crate) use validation::validate_direct_codex_user_item;
|
||||||
pub(crate) use wire::{
|
pub(crate) use wire::{
|
||||||
|
|||||||
@@ -36,6 +36,21 @@ pub(crate) enum DirectCodexUserContentPart {
|
|||||||
AgcResourceReference { resource_id: String },
|
AgcResourceReference { resource_id: String },
|
||||||
#[serde(rename = "agc_runtime_region_reference")]
|
#[serde(rename = "agc_runtime_region_reference")]
|
||||||
AgcRuntimeRegionReference(DirectCodexUserRuntimeRegionPart),
|
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)]
|
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
|
||||||
|
|||||||
+200
-6
@@ -4,6 +4,8 @@ use super::model::{
|
|||||||
};
|
};
|
||||||
use crate::agent::{
|
use crate::agent::{
|
||||||
read_manifest_for_project, sanitize_attachment_local_path, GameCreationAppManifest,
|
read_manifest_for_project, sanitize_attachment_local_path, GameCreationAppManifest,
|
||||||
|
MAX_DIRECT_CODEX_ATTACHMENTS, MAX_DIRECT_CODEX_ATTACHMENT_MEDIA_TYPE_CHARS,
|
||||||
|
MAX_DIRECT_CODEX_ATTACHMENT_NAME_CHARS,
|
||||||
};
|
};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
@@ -20,18 +22,17 @@ pub(crate) fn validate_direct_codex_user_item(
|
|||||||
if message.id.trim().is_empty() {
|
if message.id.trim().is_empty() {
|
||||||
return Err("DirectProject user item 缺少稳定 id".to_string());
|
return Err("DirectProject user item 缺少稳定 id".to_string());
|
||||||
}
|
}
|
||||||
if message.content.is_empty() {
|
// 有效输入只判一整条 content:单个纯空白 `input_text` 是合法 part —— 编辑器里的段落
|
||||||
|
// 分隔、软换行与 chip 后的分隔空格就是这样落进 canonical content 的,前端不为它过滤。
|
||||||
|
if !content_has_meaningful_input(&message.content) {
|
||||||
return Err("DirectProject user item content 不能为空".to_string());
|
return Err("DirectProject user item content 不能为空".to_string());
|
||||||
}
|
}
|
||||||
let manifest = read_manifest_for_project(root)?;
|
let manifest = read_manifest_for_project(root)?;
|
||||||
let mut reference_count = 0usize;
|
let mut reference_count = 0usize;
|
||||||
|
let mut attachment_count = 0usize;
|
||||||
for part in &message.content {
|
for part in &message.content {
|
||||||
match part {
|
match part {
|
||||||
DirectCodexUserContentPart::InputText { text } => {
|
DirectCodexUserContentPart::InputText { .. } => {}
|
||||||
if text.trim().is_empty() {
|
|
||||||
return Err("DirectProject input_text 不能为空".to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
DirectCodexUserContentPart::AgcResourceReference { resource_id } => {
|
DirectCodexUserContentPart::AgcResourceReference { resource_id } => {
|
||||||
reference_count = reference_count.saturating_add(1);
|
reference_count = reference_count.saturating_add(1);
|
||||||
validate_resource_id_and_manifest(&manifest, resource_id)?;
|
validate_resource_id_and_manifest(&manifest, resource_id)?;
|
||||||
@@ -40,6 +41,44 @@ pub(crate) fn validate_direct_codex_user_item(
|
|||||||
reference_count = reference_count.saturating_add(1);
|
reference_count = reference_count.saturating_add(1);
|
||||||
validate_runtime_region_reference(&manifest, reference)?;
|
validate_runtime_region_reference(&manifest, reference)?;
|
||||||
}
|
}
|
||||||
|
DirectCodexUserContentPart::AgcAttachmentReference(reference) => {
|
||||||
|
attachment_count = attachment_count.saturating_add(1);
|
||||||
|
if attachment_count > MAX_DIRECT_CODEX_ATTACHMENTS {
|
||||||
|
return Err(format!(
|
||||||
|
"一次最多携带 {MAX_DIRECT_CODEX_ATTACHMENTS} 个附件"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if reference.name.trim().is_empty() {
|
||||||
|
return Err("附件缺少文件名".to_string());
|
||||||
|
}
|
||||||
|
let name = reference.name.trim();
|
||||||
|
if name.chars().count() > MAX_DIRECT_CODEX_ATTACHMENT_NAME_CHARS
|
||||||
|
|| name.chars().any(char::is_control)
|
||||||
|
{
|
||||||
|
return Err("附件文件名无效或过长".to_string());
|
||||||
|
}
|
||||||
|
let media_type = reference.media_type.trim();
|
||||||
|
if media_type.is_empty()
|
||||||
|
|| media_type.chars().count() > MAX_DIRECT_CODEX_ATTACHMENT_MEDIA_TYPE_CHARS
|
||||||
|
|| media_type.chars().any(|character| {
|
||||||
|
!(character.is_ascii_alphanumeric()
|
||||||
|
|| matches!(character, '/' | '+' | '-' | '.' | '_'))
|
||||||
|
})
|
||||||
|
{
|
||||||
|
return Err("附件媒体类型无效或过长".to_string());
|
||||||
|
}
|
||||||
|
let status = reference.status.trim();
|
||||||
|
if status == "imported" && reference.local_path.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!(status, "imported" | "failed") {
|
||||||
|
return Err("附件状态无效".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if reference_count > MAX_DIRECT_CODEX_REFERENCES {
|
if reference_count > MAX_DIRECT_CODEX_REFERENCES {
|
||||||
@@ -48,6 +87,14 @@ pub(crate) fn validate_direct_codex_user_item(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 整条 content 是否还有有效输入:任何一段非空白文本、或任何一个非文本 part 都算。
|
||||||
|
pub(crate) fn content_has_meaningful_input(content: &[DirectCodexUserContentPart]) -> bool {
|
||||||
|
content.iter().any(|part| match part {
|
||||||
|
DirectCodexUserContentPart::InputText { text } => !text.trim().is_empty(),
|
||||||
|
_ => true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn validate_resource_id_and_manifest(
|
pub(crate) fn validate_resource_id_and_manifest(
|
||||||
manifest: &GameCreationAppManifest,
|
manifest: &GameCreationAppManifest,
|
||||||
resource_id: &str,
|
resource_id: &str,
|
||||||
@@ -86,3 +133,150 @@ fn validate_runtime_region_reference(
|
|||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{content_has_meaningful_input, validate_direct_codex_user_item};
|
||||||
|
use crate::agent::direct_codex_user_item::model::DirectCodexUserContentPart;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
fn input_text(text: &str) -> DirectCodexUserContentPart {
|
||||||
|
DirectCodexUserContentPart::InputText {
|
||||||
|
text: text.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_all_blank_content_counts_as_empty_input() {
|
||||||
|
// 空数组与「整条只有空白」是同一种空输入。
|
||||||
|
assert!(!content_has_meaningful_input(&[]));
|
||||||
|
assert!(!content_has_meaningful_input(&[input_text(" \n ")]));
|
||||||
|
assert!(!content_has_meaningful_input(&[
|
||||||
|
input_text("\n"),
|
||||||
|
input_text(" "),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn whitespace_parts_are_valid_next_to_meaningful_input() {
|
||||||
|
// 段落分隔 / 软换行 / chip 后的分隔空格都是合法的单个 part。
|
||||||
|
assert!(content_has_meaningful_input(&[
|
||||||
|
input_text("\n"),
|
||||||
|
input_text("看"),
|
||||||
|
]));
|
||||||
|
assert!(content_has_meaningful_input(&[
|
||||||
|
input_text("看"),
|
||||||
|
input_text("\n\n"),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_text_parts_always_count_as_input() {
|
||||||
|
assert!(content_has_meaningful_input(&[
|
||||||
|
DirectCodexUserContentPart::AgcResourceReference {
|
||||||
|
resource_id: "asset-hero".to_string(),
|
||||||
|
},
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn inline_attachment_count_is_bounded_independently() {
|
||||||
|
let root = tempfile::tempdir().expect("temp project");
|
||||||
|
crate::init_local_game_project_at(root.path(), "validation-test", "校验测试")
|
||||||
|
.expect("init project");
|
||||||
|
let content = (0..=crate::agent::MAX_DIRECT_CODEX_ATTACHMENTS)
|
||||||
|
.map(|index| {
|
||||||
|
json!({
|
||||||
|
"type": "agc_attachment_reference",
|
||||||
|
"name": format!("attachment-{index}.txt"),
|
||||||
|
"mediaType": "text/plain",
|
||||||
|
"size": 1,
|
||||||
|
"localPath": "",
|
||||||
|
"status": "failed"
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let item = serde_json::from_value(json!({
|
||||||
|
"type": "message",
|
||||||
|
"role": "user",
|
||||||
|
"content": content,
|
||||||
|
"id": "turn-1:user"
|
||||||
|
}))
|
||||||
|
.expect("deserialize user item");
|
||||||
|
let error = validate_direct_codex_user_item(root.path(), &item)
|
||||||
|
.expect_err("too many inline attachments must be rejected");
|
||||||
|
assert!(error.contains("最多携带"), "{error}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn imported_attachment_requires_a_project_path() {
|
||||||
|
let root = tempfile::tempdir().expect("temp project");
|
||||||
|
crate::init_local_game_project_at(root.path(), "validation-test", "校验测试")
|
||||||
|
.expect("init project");
|
||||||
|
let item = serde_json::from_value(json!({
|
||||||
|
"type": "message",
|
||||||
|
"role": "user",
|
||||||
|
"content": [{
|
||||||
|
"type": "agc_attachment_reference",
|
||||||
|
"name": "attachment.txt",
|
||||||
|
"mediaType": "text/plain",
|
||||||
|
"size": 1,
|
||||||
|
"localPath": "",
|
||||||
|
"status": "imported"
|
||||||
|
}],
|
||||||
|
"id": "turn-1:user"
|
||||||
|
}))
|
||||||
|
.expect("deserialize user item");
|
||||||
|
let error = validate_direct_codex_user_item(root.path(), &item)
|
||||||
|
.expect_err("imported attachment without a project path must fail");
|
||||||
|
assert!(error.contains("缺少项目路径"), "{error}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn attachment_name_and_media_type_are_bounded_and_well_formed() {
|
||||||
|
let root = tempfile::tempdir().expect("temp project");
|
||||||
|
crate::init_local_game_project_at(root.path(), "validation-test", "校验测试")
|
||||||
|
.expect("init project");
|
||||||
|
let long_name = "a".repeat(crate::agent::MAX_DIRECT_CODEX_ATTACHMENT_NAME_CHARS + 1);
|
||||||
|
let cases = [
|
||||||
|
(
|
||||||
|
json!({
|
||||||
|
"name": "bad\nname.txt",
|
||||||
|
"mediaType": "text/plain"
|
||||||
|
}),
|
||||||
|
"文件名",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
json!({
|
||||||
|
"name": "ok.txt",
|
||||||
|
"mediaType": "text/plain\nsecret"
|
||||||
|
}),
|
||||||
|
"媒体类型",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
json!({
|
||||||
|
"name": long_name,
|
||||||
|
"mediaType": "text/plain"
|
||||||
|
}),
|
||||||
|
"文件名",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
for (metadata, expected) in cases {
|
||||||
|
let mut value = metadata;
|
||||||
|
value["type"] = json!("agc_attachment_reference");
|
||||||
|
value["size"] = json!(1);
|
||||||
|
value["localPath"] = json!("");
|
||||||
|
value["status"] = json!("failed");
|
||||||
|
let item = serde_json::from_value(json!({
|
||||||
|
"type": "message",
|
||||||
|
"role": "user",
|
||||||
|
"content": [value],
|
||||||
|
"id": "turn-1:user"
|
||||||
|
}))
|
||||||
|
.expect("deserialize user item");
|
||||||
|
let error = validate_direct_codex_user_item(root.path(), &item)
|
||||||
|
.expect_err("invalid attachment metadata must fail");
|
||||||
|
assert!(error.contains(expected), "{error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
use super::model::{DirectCodexUserContentPart, DirectCodexUserItem};
|
use super::model::{DirectCodexUserContentPart, DirectCodexUserItem};
|
||||||
use super::validation::validate_direct_codex_user_item;
|
use super::validation::validate_direct_codex_user_item;
|
||||||
use crate::agent::{read_manifest_for_project, sanitize_attachment_local_path};
|
use crate::agent::{
|
||||||
|
read_manifest_for_project, sanitize_attachment_local_path, sanitize_attachment_media_type,
|
||||||
|
sanitize_attachment_name,
|
||||||
|
};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
@@ -100,6 +103,21 @@ pub(crate) fn direct_codex_user_item_to_wire_input(
|
|||||||
summary.push(']');
|
summary.push(']');
|
||||||
summary
|
summary
|
||||||
}
|
}
|
||||||
|
DirectCodexUserContentPart::AgcAttachmentReference(reference) => {
|
||||||
|
let name = sanitize_attachment_name(&reference.name);
|
||||||
|
let media_type = sanitize_attachment_media_type(&reference.media_type);
|
||||||
|
let local_path = sanitize_attachment_local_path(&reference.local_path);
|
||||||
|
let mut summary = format!(
|
||||||
|
"[附件:名称={};类型={};大小={} 字节",
|
||||||
|
name, media_type, reference.size
|
||||||
|
);
|
||||||
|
if let Some(local_path) = local_path {
|
||||||
|
summary.push_str(&format!(";项目路径={local_path}"));
|
||||||
|
}
|
||||||
|
summary.push_str(&format!(";状态={}", reference.status.trim()));
|
||||||
|
summary.push(']');
|
||||||
|
summary
|
||||||
|
}
|
||||||
};
|
};
|
||||||
input.push(serde_json::json!({ "type": "text", "text": text }));
|
input.push(serde_json::json!({ "type": "text", "text": text }));
|
||||||
}
|
}
|
||||||
@@ -130,7 +148,8 @@ pub(crate) fn direct_codex_user_item_to_prompt(
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::direct_codex_user_item_to_response_item;
|
use super::{direct_codex_user_item_to_response_item, direct_codex_user_item_to_wire_input};
|
||||||
|
use crate::agent::direct_codex_user_item::model::DirectCodexUserItem;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
@@ -174,4 +193,99 @@ mod tests {
|
|||||||
.expect_err("history item without type must fail");
|
.expect_err("history item without type must fail");
|
||||||
assert!(error.contains("缺少 type"), "{error}");
|
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"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn attachment_metadata_is_sanitized_before_prompt_projection() {
|
||||||
|
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": "agc_attachment_reference",
|
||||||
|
"name": "C:\\tmp\\notes.md",
|
||||||
|
"mediaType": "text/plain",
|
||||||
|
"size": 4,
|
||||||
|
"localPath": "assets\\.\\notes.txt",
|
||||||
|
"status": "imported"
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
let wire = super::direct_codex_user_item_to_wire_input(
|
||||||
|
root.path(),
|
||||||
|
&serde_json::from_value(item).expect("deserialize user item"),
|
||||||
|
)
|
||||||
|
.expect("attachment metadata should project");
|
||||||
|
let text = wire[0]["text"].as_str().expect("wire text");
|
||||||
|
assert!(text.contains("名称=notes.md"), "{text}");
|
||||||
|
assert!(text.contains("类型=text/plain"), "{text}");
|
||||||
|
assert!(text.contains("项目路径=assets/notes.txt"), "{text}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn whitespace_only_text_parts_survive_validation() {
|
||||||
|
let root = tempfile::tempdir().expect("temp project");
|
||||||
|
crate::init_local_game_project_at(root.path(), "wire-test", "wire 投影测试")
|
||||||
|
.expect("init project");
|
||||||
|
let item: DirectCodexUserItem = serde_json::from_value(json!({
|
||||||
|
"type": "message",
|
||||||
|
"role": "user",
|
||||||
|
"id": "turn-1:user",
|
||||||
|
"content": [
|
||||||
|
{"type": "input_text", "text": "先看"},
|
||||||
|
{"type": "input_text", "text": "\n"},
|
||||||
|
{"type": "input_text", "text": " "}
|
||||||
|
]
|
||||||
|
}))
|
||||||
|
.expect("deserialize user item");
|
||||||
|
let wire = direct_codex_user_item_to_wire_input(root.path(), &item)
|
||||||
|
.expect("whitespace-only part next to real text must pass");
|
||||||
|
let parts = wire.as_array().expect("wire input array");
|
||||||
|
assert_eq!(parts.len(), 3);
|
||||||
|
assert_eq!(parts[1]["text"].as_str(), Some("\n"));
|
||||||
|
assert_eq!(parts[2]["text"].as_str(), Some(" "));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn all_blank_content_is_rejected() {
|
||||||
|
let root = tempfile::tempdir().expect("temp project");
|
||||||
|
crate::init_local_game_project_at(root.path(), "wire-test", "wire 投影测试")
|
||||||
|
.expect("init project");
|
||||||
|
let item: DirectCodexUserItem = serde_json::from_value(json!({
|
||||||
|
"type": "message",
|
||||||
|
"role": "user",
|
||||||
|
"id": "turn-1:user",
|
||||||
|
"content": [
|
||||||
|
{"type": "input_text", "text": "\n"},
|
||||||
|
{"type": "input_text", "text": " "}
|
||||||
|
]
|
||||||
|
}))
|
||||||
|
.expect("deserialize user item");
|
||||||
|
let error = direct_codex_user_item_to_wire_input(root.path(), &item)
|
||||||
|
.expect_err("all-blank content must fail closed");
|
||||||
|
assert!(error.contains("不能为空"), "{error}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,10 +31,9 @@ pub(crate) fn normalize_direct_client_turn_id(
|
|||||||
pub(crate) async fn chat_with_game_creator_direct_codex(
|
pub(crate) async fn chat_with_game_creator_direct_codex(
|
||||||
project_path: String,
|
project_path: String,
|
||||||
prompt: String,
|
prompt: String,
|
||||||
mut user_item: DirectCodexUserItem,
|
user_item: DirectCodexUserItem,
|
||||||
creation_type: Option<String>,
|
creation_type: Option<String>,
|
||||||
client_turn_id: Option<String>,
|
client_turn_id: Option<String>,
|
||||||
attachments: Option<Vec<DirectCodexTurnAttachment>>,
|
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
let root = Path::new(project_path.trim());
|
let root = Path::new(project_path.trim());
|
||||||
let turn_id = normalize_direct_client_turn_id(client_turn_id.as_deref())?;
|
let turn_id = normalize_direct_client_turn_id(client_turn_id.as_deref())?;
|
||||||
@@ -43,34 +42,9 @@ pub(crate) async fn chat_with_game_creator_direct_codex(
|
|||||||
redact_agent_runtime_error(root, &format!("恢复上一轮陶泥儿整包事务失败:{error}"), 500)
|
redact_agent_runtime_error(root, &format!("恢复上一轮陶泥儿整包事务失败:{error}"), 500)
|
||||||
})?;
|
})?;
|
||||||
let turn_emitter = DirectGameCreatorTurnUpdateEmitter::new(root, turn_id.clone());
|
let turn_emitter = DirectGameCreatorTurnUpdateEmitter::new(root, turn_id.clone());
|
||||||
let mut audit = DirectCodexTurnAudit::start(
|
validate_direct_codex_user_item(root, &user_item)?;
|
||||||
root,
|
let user_prompt = direct_codex_user_item_to_prompt(root, &user_item)?;
|
||||||
&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() {
|
if user_prompt.trim().is_empty() {
|
||||||
audit.finish(false);
|
|
||||||
return Err("聊天内容不能为空".to_string());
|
return Err("聊天内容不能为空".to_string());
|
||||||
}
|
}
|
||||||
let canonical_user_item =
|
let canonical_user_item =
|
||||||
@@ -80,18 +54,15 @@ pub(crate) async fn chat_with_game_creator_direct_codex(
|
|||||||
&user_prompt,
|
&user_prompt,
|
||||||
creation_type.as_deref(),
|
creation_type.as_deref(),
|
||||||
Some(&turn_emitter),
|
Some(&turn_emitter),
|
||||||
Some(&mut audit),
|
// DirectProject 的完整回合权威已经落在 project.jsonl;不再创建平行审计日志。
|
||||||
|
None,
|
||||||
canonical_user_item,
|
canonical_user_item,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(reply) => reply,
|
Ok(reply) => reply,
|
||||||
Err(error) => {
|
Err(error) => return Err(error),
|
||||||
audit.finish(false);
|
|
||||||
return Err(error);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
audit.finish(true);
|
|
||||||
turn_emitter.emit("completed", Some("none"), Some(reply.clone()), None);
|
turn_emitter.emit("completed", Some("none"), Some(reply.clone()), None);
|
||||||
Ok(reply)
|
Ok(reply)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -291,7 +291,11 @@ import type {
|
|||||||
} from './features/project-workspace/resourceReferences';
|
} from './features/project-workspace/resourceReferences';
|
||||||
import {
|
import {
|
||||||
chatComposerDraftToDirectCodexUserItem,
|
chatComposerDraftToDirectCodexUserItem,
|
||||||
|
directCodexContentToPromptText,
|
||||||
|
directCodexUserItemFromContent,
|
||||||
|
hasMeaningfulDirectCodexContent,
|
||||||
RESOURCE_REFERENCE_INSERT_EVENT,
|
RESOURCE_REFERENCE_INSERT_EVENT,
|
||||||
|
resourceReferenceFromAsset,
|
||||||
type ResourceReferenceInsertEventDetail,
|
type ResourceReferenceInsertEventDetail,
|
||||||
} from './features/project-workspace/resourceReferences';
|
} from './features/project-workspace/resourceReferences';
|
||||||
import { SupervisorChatOnlyView } from './features/project-workspace/SupervisorChatOnlyView';
|
import { SupervisorChatOnlyView } from './features/project-workspace/SupervisorChatOnlyView';
|
||||||
@@ -4072,9 +4076,39 @@ export function App({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleChatComposerChange(draft: ChatComposerDraft) {
|
function handleChatComposerChange(draft: ChatComposerDraft) {
|
||||||
setChatInput(draft.text);
|
const content = draft.content;
|
||||||
setChatReferences(draft.references);
|
setChatContent(content);
|
||||||
setChatContent(draft.content ?? []);
|
setChatInput(directCodexContentToPromptText(content, manifest.assets));
|
||||||
|
setChatReferences(
|
||||||
|
content.flatMap((part) => {
|
||||||
|
if (part.type === 'agc_resource_reference') {
|
||||||
|
const asset = manifest.assets.find(
|
||||||
|
(item) => item.id === part.resourceId,
|
||||||
|
);
|
||||||
|
return asset
|
||||||
|
? [resourceReferenceFromAsset(asset, 'asset-picker')]
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
if (part.type === 'agc_runtime_region_reference') {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: 'runtime-region' as const,
|
||||||
|
label: part.label,
|
||||||
|
runId: part.runId ?? undefined,
|
||||||
|
versionId: part.versionId ?? undefined,
|
||||||
|
elementTag: part.elementTag ?? undefined,
|
||||||
|
elementRole: part.elementRole ?? undefined,
|
||||||
|
text: part.text ?? undefined,
|
||||||
|
width: part.width ?? undefined,
|
||||||
|
height: part.height ?? undefined,
|
||||||
|
resourceIds: part.resourceIds,
|
||||||
|
source: 'runtime-picker' as const,
|
||||||
|
},
|
||||||
|
] as ChatReference[];
|
||||||
|
}
|
||||||
|
return [] as ChatReference[];
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -6474,12 +6508,13 @@ export function App({
|
|||||||
if (directProjectPath && directProjectId && directInvoke) {
|
if (directProjectPath && directProjectId && directInvoke) {
|
||||||
const clientTurnId =
|
const clientTurnId =
|
||||||
directConversationTurnId ?? createDirectCodexConversationTurnId();
|
directConversationTurnId ?? createDirectCodexConversationTurnId();
|
||||||
const effectiveUserItem =
|
if (!userItem) {
|
||||||
userItem ??
|
setProjectSupervisorRuntimeError(
|
||||||
chatComposerDraftToDirectCodexUserItem(
|
'DirectProject 回合缺少 canonical user item,未发送本轮消息。',
|
||||||
{ text: prompt, references: references ?? [], content: [] },
|
|
||||||
directCodexConversationMessageId(clientTurnId, 'user'),
|
|
||||||
);
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const effectiveUserItem = userItem;
|
||||||
if (
|
if (
|
||||||
!directPolicyChecked &&
|
!directPolicyChecked &&
|
||||||
projectConversationWriteConfirmedRef.current !== directProjectPath
|
projectConversationWriteConfirmedRef.current !== directProjectPath
|
||||||
@@ -6606,7 +6641,6 @@ export function App({
|
|||||||
prompt: string;
|
prompt: string;
|
||||||
clientTurnId: string;
|
clientTurnId: string;
|
||||||
creationType?: HomeCreationType;
|
creationType?: HomeCreationType;
|
||||||
attachments?: DirectCodexTurnAttachment[];
|
|
||||||
userItem: ReturnType<typeof chatComposerDraftToDirectCodexUserItem>;
|
userItem: ReturnType<typeof chatComposerDraftToDirectCodexUserItem>;
|
||||||
} = {
|
} = {
|
||||||
projectPath: directProjectPath,
|
projectPath: directProjectPath,
|
||||||
@@ -6617,9 +6651,6 @@ export function App({
|
|||||||
if (creationType) {
|
if (creationType) {
|
||||||
directTurnInput.creationType = creationType;
|
directTurnInput.creationType = creationType;
|
||||||
}
|
}
|
||||||
if (attachments?.length) {
|
|
||||||
directTurnInput.attachments = attachments;
|
|
||||||
}
|
|
||||||
directTurnInput.userItem = effectiveUserItem;
|
directTurnInput.userItem = effectiveUserItem;
|
||||||
// 回复正文按条目身份从线程事件 / 历史切片进聊天,本地不再补一条,
|
// 回复正文按条目身份从线程事件 / 历史切片进聊天,本地不再补一条,
|
||||||
// 因此这里只等回合跑完,不接返回值。
|
// 因此这里只等回合跑完,不接返回值。
|
||||||
@@ -6704,6 +6735,7 @@ export function App({
|
|||||||
setDirectCodexTurnCancelling(false);
|
setDirectCodexTurnCancelling(false);
|
||||||
chatAgentBusyRef.current = false;
|
chatAgentBusyRef.current = false;
|
||||||
// 本地 invoke 正常收尾仍可直接推进队列;恢复场景则由 turn.completed effect 推进。
|
// 本地 invoke 正常收尾仍可直接推进队列;恢复场景则由 turn.completed effect 推进。
|
||||||
|
// 只有当前回合的收尾才能释放发送队列,不能覆盖后来启动的回合。
|
||||||
dispatchNextQueuedChatTurn();
|
dispatchNextQueuedChatTurn();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6925,11 +6957,21 @@ export function App({
|
|||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
const initialUserItem = directConversationTurnId
|
||||||
|
? directCodexUserItemFromContent(
|
||||||
|
[
|
||||||
|
{ type: 'input_text', text: latch.prompt },
|
||||||
|
...attachmentContentParts(latch.attachments),
|
||||||
|
],
|
||||||
|
directCodexConversationMessageId(directConversationTurnId, 'user'),
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
void executeChatAgentReplyRef.current({
|
void executeChatAgentReplyRef.current({
|
||||||
prompt: latch.prompt,
|
prompt: latch.prompt,
|
||||||
clientTurnId: directConversationTurnId,
|
clientTurnId: directConversationTurnId,
|
||||||
creationType: latch.creationType,
|
creationType: latch.creationType,
|
||||||
attachments: latch.attachments,
|
attachments: latch.attachments,
|
||||||
|
...(initialUserItem ? { userItem: initialUserItem } : {}),
|
||||||
});
|
});
|
||||||
}, [
|
}, [
|
||||||
chatAgentBusy,
|
chatAgentBusy,
|
||||||
@@ -11990,36 +12032,28 @@ export function App({
|
|||||||
* 消息落盘/回合 id/附件参数走样。
|
* 消息落盘/回合 id/附件参数走样。
|
||||||
*/
|
*/
|
||||||
function startDirectCodexConversationTurn(input: {
|
function startDirectCodexConversationTurn(input: {
|
||||||
prompt: string;
|
clientTurnId: string;
|
||||||
attachments?: DirectCodexTurnAttachment[];
|
userItem: ReturnType<typeof chatComposerDraftToDirectCodexUserItem>;
|
||||||
references?: ChatReference[];
|
|
||||||
content?: DirectCodexUserContentPart[];
|
|
||||||
}) {
|
}) {
|
||||||
const clientTurnId = createDirectCodexConversationTurnId();
|
const prompt = directCodexContentToPromptText(
|
||||||
|
input.userItem.content,
|
||||||
|
manifest.assets,
|
||||||
|
);
|
||||||
supervisorChatShouldFollowLatestRef.current = true;
|
supervisorChatShouldFollowLatestRef.current = true;
|
||||||
setMessages((current) => [
|
setMessages((current) => [
|
||||||
...current,
|
...current,
|
||||||
{
|
{
|
||||||
role: 'user',
|
role: 'user',
|
||||||
text: input.prompt,
|
text: prompt,
|
||||||
runtimeOwned: true,
|
runtimeOwned: true,
|
||||||
messageId: directCodexConversationMessageId(clientTurnId, 'user'),
|
messageId: directCodexConversationMessageId(input.clientTurnId, 'user'),
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
void executeChatAgentReply({
|
void executeChatAgentReply({
|
||||||
prompt: input.prompt,
|
prompt,
|
||||||
clientTurnId,
|
clientTurnId: input.clientTurnId,
|
||||||
attachments: input.attachments?.length ? input.attachments : undefined,
|
userItem: input.userItem,
|
||||||
references: input.references,
|
|
||||||
userItem: chatComposerDraftToDirectCodexUserItem(
|
|
||||||
{
|
|
||||||
text: input.prompt,
|
|
||||||
references: input.references ?? [],
|
|
||||||
content: input.content ?? [],
|
|
||||||
},
|
|
||||||
directCodexConversationMessageId(clientTurnId, 'user'),
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -12080,12 +12114,24 @@ export function App({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function attachmentContentParts(
|
||||||
|
attachments: readonly DirectCodexTurnAttachment[],
|
||||||
|
): DirectCodexUserContentPart[] {
|
||||||
|
return attachments.map((attachment) => ({
|
||||||
|
type: 'agc_attachment_reference' as const,
|
||||||
|
name: attachment.name,
|
||||||
|
mediaType: attachment.mediaType,
|
||||||
|
size: attachment.size ?? 0,
|
||||||
|
localPath: attachment.localPath ?? '',
|
||||||
|
status:
|
||||||
|
attachment.status ?? (attachment.localPath ? 'imported' : 'failed'),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
/** 回合运行中再次发送:进本地 FIFO 队列;队列满时拒绝并保留草稿,不静默丢消息。 */
|
/** 回合运行中再次发送:进本地 FIFO 队列;队列满时拒绝并保留草稿,不静默丢消息。 */
|
||||||
function enqueueChatTurnForRunningTurn(input: {
|
function enqueueChatTurnForRunningTurn(input: {
|
||||||
prompt: string;
|
clientTurnId: string;
|
||||||
attachments: DirectCodexTurnAttachment[];
|
userItem: ReturnType<typeof chatComposerDraftToDirectCodexUserItem>;
|
||||||
references: ChatReference[];
|
|
||||||
content: DirectCodexUserContentPart[];
|
|
||||||
}): boolean {
|
}): boolean {
|
||||||
if (isChatTurnQueueFull(chatTurnQueueRef.current)) {
|
if (isChatTurnQueueFull(chatTurnQueueRef.current)) {
|
||||||
setChatComposerNotice(chatQueueFullNotice());
|
setChatComposerNotice(chatQueueFullNotice());
|
||||||
@@ -12094,10 +12140,8 @@ export function App({
|
|||||||
queuedChatTurnSequenceRef.current += 1;
|
queuedChatTurnSequenceRef.current += 1;
|
||||||
const turn = createQueuedChatTurn({
|
const turn = createQueuedChatTurn({
|
||||||
id: `queued-chat-turn-${Date.now()}-${queuedChatTurnSequenceRef.current}`,
|
id: `queued-chat-turn-${Date.now()}-${queuedChatTurnSequenceRef.current}`,
|
||||||
prompt: input.prompt,
|
clientTurnId: input.clientTurnId,
|
||||||
attachments: input.attachments,
|
userItem: input.userItem,
|
||||||
references: input.references,
|
|
||||||
content: input.content,
|
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
});
|
});
|
||||||
const nextQueue = enqueueChatTurn(chatTurnQueueRef.current, turn);
|
const nextQueue = enqueueChatTurn(chatTurnQueueRef.current, turn);
|
||||||
@@ -12132,10 +12176,8 @@ export function App({
|
|||||||
setChatComposerNotice('');
|
setChatComposerNotice('');
|
||||||
}
|
}
|
||||||
startDirectCodexConversationTurn({
|
startDirectCodexConversationTurn({
|
||||||
prompt: next.prompt,
|
clientTurnId: next.clientTurnId,
|
||||||
attachments: next.attachments,
|
userItem: next.userItem,
|
||||||
references: next.references,
|
|
||||||
content: next.content,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -12216,6 +12258,14 @@ export function App({
|
|||||||
const prompt = chatInput.trim();
|
const prompt = chatInput.trim();
|
||||||
const references = chatReferences;
|
const references = chatReferences;
|
||||||
const pendingAttachments = chatAttachments;
|
const pendingAttachments = chatAttachments;
|
||||||
|
const content = [
|
||||||
|
...chatContent,
|
||||||
|
...attachmentContentParts(pendingAttachments),
|
||||||
|
];
|
||||||
|
const canonicalPrompt = directCodexContentToPromptText(
|
||||||
|
content,
|
||||||
|
manifest.assets,
|
||||||
|
);
|
||||||
if (
|
if (
|
||||||
!directCodexProductRuntime &&
|
!directCodexProductRuntime &&
|
||||||
supervisorChatOnly &&
|
supervisorChatOnly &&
|
||||||
@@ -12231,23 +12281,20 @@ export function App({
|
|||||||
setProjectSupervisorRuntimeError('请先回答项目总控 Agent 当前的澄清问题');
|
setProjectSupervisorRuntimeError('请先回答项目总控 Agent 当前的澄清问题');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (
|
if (!hasMeaningfulDirectCodexContent(content)) {
|
||||||
!prompt &&
|
|
||||||
references.length === 0 &&
|
|
||||||
chatContent.length === 0 &&
|
|
||||||
pendingAttachments.length === 0
|
|
||||||
) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (supervisorChatBusy) {
|
if (supervisorChatBusy) {
|
||||||
// 回合运行中再次发送:direct-codex 面板把消息放进本地 FIFO 队列,当前回合结束后
|
// 回合运行中再次发送:direct-codex 面板把消息放进本地 FIFO 队列,当前回合结束后
|
||||||
// 依次发出;其它面板保持原有"运行中不接受新输入"的行为。
|
// 依次发出;其它面板保持原有"运行中不接受新输入"的行为。
|
||||||
if (directCodexProductRuntime) {
|
if (directCodexProductRuntime) {
|
||||||
|
const clientTurnId = createDirectCodexConversationTurnId();
|
||||||
const enqueued = enqueueChatTurnForRunningTurn({
|
const enqueued = enqueueChatTurnForRunningTurn({
|
||||||
prompt,
|
clientTurnId,
|
||||||
attachments: pendingAttachments,
|
userItem: directCodexUserItemFromContent(
|
||||||
references,
|
content,
|
||||||
content: chatContent,
|
directCodexConversationMessageId(clientTurnId, 'user'),
|
||||||
|
),
|
||||||
});
|
});
|
||||||
if (enqueued) {
|
if (enqueued) {
|
||||||
setChatInput('');
|
setChatInput('');
|
||||||
@@ -12271,7 +12318,7 @@ export function App({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (planningV2ActiveRef.current || planningStartMode) {
|
if (planningV2ActiveRef.current || planningStartMode) {
|
||||||
if (!prompt || chatAgentBusy) {
|
if (!canonicalPrompt || chatAgentBusy) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
supervisorChatShouldFollowLatestRef.current = true;
|
supervisorChatShouldFollowLatestRef.current = true;
|
||||||
@@ -12281,13 +12328,13 @@ export function App({
|
|||||||
...current,
|
...current,
|
||||||
{
|
{
|
||||||
role: 'user',
|
role: 'user',
|
||||||
text: prompt,
|
text: canonicalPrompt,
|
||||||
runtimeOwned: true,
|
runtimeOwned: true,
|
||||||
messageId: `planning-v2:${clientTurnId}:user`,
|
messageId: `planning-v2:${clientTurnId}:user`,
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
void executeChatAgentReply({ prompt, clientTurnId });
|
void executeChatAgentReply({ prompt: canonicalPrompt, clientTurnId });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (supervisorChatOnly || directCodexProductRuntime) {
|
if (supervisorChatOnly || directCodexProductRuntime) {
|
||||||
@@ -12301,11 +12348,13 @@ export function App({
|
|||||||
setChatContent([]);
|
setChatContent([]);
|
||||||
setChatAttachmentNotice('');
|
setChatAttachmentNotice('');
|
||||||
setChatComposerNotice('');
|
setChatComposerNotice('');
|
||||||
|
const clientTurnId = createDirectCodexConversationTurnId();
|
||||||
startDirectCodexConversationTurn({
|
startDirectCodexConversationTurn({
|
||||||
prompt,
|
clientTurnId,
|
||||||
attachments: pendingAttachments,
|
userItem: directCodexUserItemFromContent(
|
||||||
references,
|
content,
|
||||||
content: chatContent,
|
directCodexConversationMessageId(clientTurnId, 'user'),
|
||||||
|
),
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
||||||
|
import { $getNodeByKey, type NodeKey } from 'lexical';
|
||||||
|
import { Paperclip, X } from 'lucide-react';
|
||||||
|
|
||||||
|
import type { DirectCodexUserAttachmentReferencePart } from './generated';
|
||||||
|
|
||||||
|
export function AttachmentReferenceChip({
|
||||||
|
attachment,
|
||||||
|
nodeKey,
|
||||||
|
}: {
|
||||||
|
attachment: DirectCodexUserAttachmentReferencePart;
|
||||||
|
nodeKey: NodeKey;
|
||||||
|
}) {
|
||||||
|
const [editor] = useLexicalComposerContext();
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="resource-reference-chip resource-attachment-chip"
|
||||||
|
data-attachment-reference="true"
|
||||||
|
data-attachment-status={attachment.status}
|
||||||
|
contentEditable={false}
|
||||||
|
title={`${attachment.name} · ${attachment.mediaType}`}
|
||||||
|
>
|
||||||
|
<Paperclip size={12} aria-hidden="true" />
|
||||||
|
<span className="resource-reference-chip-label">{attachment.name}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`移除附件 ${attachment.name}`}
|
||||||
|
onMouseDown={(event) => event.preventDefault()}
|
||||||
|
onClick={() => {
|
||||||
|
editor.update(() => {
|
||||||
|
const node = $getNodeByKey(nodeKey);
|
||||||
|
node?.remove();
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X size={11} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
+107
@@ -0,0 +1,107 @@
|
|||||||
|
import {
|
||||||
|
$applyNodeReplacement,
|
||||||
|
DecoratorNode,
|
||||||
|
type EditorConfig,
|
||||||
|
type LexicalNode,
|
||||||
|
type NodeKey,
|
||||||
|
type SerializedLexicalNode,
|
||||||
|
type Spread,
|
||||||
|
} from 'lexical';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
import { AttachmentReferenceChip } from './AttachmentReferenceChip';
|
||||||
|
import type { DirectCodexUserAttachmentReferencePart } from './generated';
|
||||||
|
|
||||||
|
export type SerializedAttachmentReferenceNode = Spread<
|
||||||
|
{
|
||||||
|
attachment: DirectCodexUserAttachmentReferencePart;
|
||||||
|
type: 'attachment-reference';
|
||||||
|
version: 1;
|
||||||
|
},
|
||||||
|
SerializedLexicalNode
|
||||||
|
>;
|
||||||
|
|
||||||
|
/** 附件在纯文本里的唯一占位字符:一个附件 chip 就当一个字符。 */
|
||||||
|
const OBJECT_REPLACEMENT = '\uFFFC';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inline canonical attachment part rendered inside the Lexical composer.
|
||||||
|
* The node stores the complete part so read-back preserves the project path and status.
|
||||||
|
*/
|
||||||
|
export class AttachmentReferenceNode extends DecoratorNode<ReactNode> {
|
||||||
|
__attachment: DirectCodexUserAttachmentReferencePart;
|
||||||
|
|
||||||
|
static getType() {
|
||||||
|
return 'attachment-reference';
|
||||||
|
}
|
||||||
|
|
||||||
|
static clone(node: AttachmentReferenceNode) {
|
||||||
|
return new AttachmentReferenceNode(node.__attachment, node.__key);
|
||||||
|
}
|
||||||
|
|
||||||
|
static importJSON(serializedNode: SerializedAttachmentReferenceNode) {
|
||||||
|
return $createAttachmentReferenceNode(serializedNode.attachment);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
attachment: DirectCodexUserAttachmentReferencePart,
|
||||||
|
key?: NodeKey,
|
||||||
|
) {
|
||||||
|
super(key);
|
||||||
|
this.__attachment = attachment;
|
||||||
|
}
|
||||||
|
|
||||||
|
exportJSON(): SerializedAttachmentReferenceNode {
|
||||||
|
return {
|
||||||
|
...super.exportJSON(),
|
||||||
|
attachment: this.__attachment,
|
||||||
|
type: 'attachment-reference',
|
||||||
|
version: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
createDOM(_config: EditorConfig) {
|
||||||
|
return document.createElement('span');
|
||||||
|
}
|
||||||
|
|
||||||
|
updateDOM() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
getTextContent() {
|
||||||
|
return OBJECT_REPLACEMENT;
|
||||||
|
}
|
||||||
|
|
||||||
|
getTextContentSize() {
|
||||||
|
return OBJECT_REPLACEMENT.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
isInline() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
isKeyboardSelectable() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
decorate() {
|
||||||
|
return (
|
||||||
|
<AttachmentReferenceChip
|
||||||
|
attachment={this.__attachment}
|
||||||
|
nodeKey={this.__key}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function $createAttachmentReferenceNode(
|
||||||
|
attachment: DirectCodexUserAttachmentReferencePart,
|
||||||
|
) {
|
||||||
|
return $applyNodeReplacement(new AttachmentReferenceNode(attachment));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function $isAttachmentReferenceNode(
|
||||||
|
node: LexicalNode | null | undefined,
|
||||||
|
): node is AttachmentReferenceNode {
|
||||||
|
return node instanceof AttachmentReferenceNode;
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
import type { RefObject } from 'react';
|
import type { RefObject } from 'react';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import type { GameCreationAppAssetManifestEntry } from '../../../../../packages/shared/src/contracts/gameCreationApp';
|
||||||
import { resolveTauriInvoke } from '../../app/tauri';
|
import { resolveTauriInvoke } from '../../app/tauri';
|
||||||
import type {
|
import type {
|
||||||
GameCreatorAppConfigView,
|
GameCreatorAppConfigView,
|
||||||
@@ -192,9 +193,12 @@ export function ComposerPendingAttachments({
|
|||||||
/** 队列 chip:回合运行中入队的消息,按 FIFO 顺序展示,可单条取消。 */
|
/** 队列 chip:回合运行中入队的消息,按 FIFO 顺序展示,可单条取消。 */
|
||||||
export function ComposerTurnQueue({
|
export function ComposerTurnQueue({
|
||||||
turns,
|
turns,
|
||||||
|
assets,
|
||||||
onCancel,
|
onCancel,
|
||||||
}: {
|
}: {
|
||||||
turns: readonly QueuedChatTurn[];
|
turns: readonly QueuedChatTurn[];
|
||||||
|
/** 与聊天消息渲染同源的素材清单:chip 文案里的 `@` 引用按它展开成显示名。 */
|
||||||
|
assets: readonly GameCreationAppAssetManifestEntry[];
|
||||||
onCancel: (id: string) => void;
|
onCancel: (id: string) => void;
|
||||||
}) {
|
}) {
|
||||||
if (turns.length === 0) {
|
if (turns.length === 0) {
|
||||||
@@ -211,11 +215,11 @@ export function ComposerTurnQueue({
|
|||||||
{index + 1}
|
{index + 1}
|
||||||
</span>
|
</span>
|
||||||
<span className="project-supervisor-composer-queue-text">
|
<span className="project-supervisor-composer-queue-text">
|
||||||
{queuedChatTurnLabel(turn)}
|
{queuedChatTurnLabel(turn, assets)}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={`取消排队消息 ${queuedChatTurnLabel(turn)}`}
|
aria-label={`取消排队消息 ${queuedChatTurnLabel(turn, assets)}`}
|
||||||
title="取消这条排队消息"
|
title="取消这条排队消息"
|
||||||
onClick={() => onCancel(turn.id)}
|
onClick={() => onCancel(turn.id)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -692,6 +692,7 @@ export function ProjectSupervisorView({
|
|||||||
>
|
>
|
||||||
<ComposerTurnQueue
|
<ComposerTurnQueue
|
||||||
turns={directCodex ? queuedTurns : []}
|
turns={directCodex ? queuedTurns : []}
|
||||||
|
assets={chatProjectAssets}
|
||||||
onCancel={(id) => onCancelQueuedTurn?.(id)}
|
onCancel={(id) => onCancelQueuedTurn?.(id)}
|
||||||
/>
|
/>
|
||||||
<ComposerPendingAttachments
|
<ComposerPendingAttachments
|
||||||
|
|||||||
+286
-127
File diff suppressed because it is too large
Load Diff
@@ -4,36 +4,33 @@
|
|||||||
* 回合运行中用户再次发送时,消息进入 FIFO 队列而不是被丢弃;当前回合结束后按入队顺序
|
* 回合运行中用户再次发送时,消息进入 FIFO 队列而不是被丢弃;当前回合结束后按入队顺序
|
||||||
* 依次发出。队列项能在输入盒上方单独取消。这里只放与 React 无关的纯逻辑,便于单测。
|
* 依次发出。队列项能在输入盒上方单独取消。这里只放与 React 无关的纯逻辑,便于单测。
|
||||||
*/
|
*/
|
||||||
import type { DirectCodexTurnAttachment } from '../app-shell/directCodexTurnAttachments';
|
import type { GameCreationAppAssetManifestEntry } from '../../../../../packages/shared/src/contracts/gameCreationApp';
|
||||||
import type { DirectCodexUserContentPart } from './generated';
|
import type { DirectCodexUserItem } from './generated';
|
||||||
import type { ChatReference } from './resourceReferences';
|
import { directCodexContentToPromptText } from './resourceReferences';
|
||||||
|
|
||||||
/** 队列上限:满了以后拒绝入队并给出可读提示,而不是静默丢消息。 */
|
/** 队列上限:满了以后拒绝入队并给出可读提示,而不是静默丢消息。 */
|
||||||
export const MAX_QUEUED_CHAT_TURNS = 5;
|
export const MAX_QUEUED_CHAT_TURNS = 5;
|
||||||
|
|
||||||
export type QueuedChatTurn = {
|
export type QueuedChatTurn = {
|
||||||
id: string;
|
id: string;
|
||||||
prompt: string;
|
clientTurnId: string;
|
||||||
attachments: DirectCodexTurnAttachment[];
|
userItem: DirectCodexUserItem;
|
||||||
references: ChatReference[];
|
|
||||||
content?: DirectCodexUserContentPart[];
|
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createQueuedChatTurn(input: {
|
export function createQueuedChatTurn(input: {
|
||||||
id: string;
|
id: string;
|
||||||
prompt: string;
|
clientTurnId: string;
|
||||||
attachments?: readonly DirectCodexTurnAttachment[];
|
userItem: DirectCodexUserItem;
|
||||||
references?: readonly ChatReference[];
|
|
||||||
content?: readonly DirectCodexUserContentPart[];
|
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
}): QueuedChatTurn {
|
}): QueuedChatTurn {
|
||||||
return {
|
return {
|
||||||
id: input.id,
|
id: input.id,
|
||||||
prompt: input.prompt,
|
clientTurnId: input.clientTurnId,
|
||||||
attachments: [...(input.attachments ?? [])],
|
userItem: {
|
||||||
references: [...(input.references ?? [])],
|
...input.userItem,
|
||||||
content: [...(input.content ?? [])],
|
content: [...input.userItem.content],
|
||||||
|
},
|
||||||
createdAt: input.createdAt,
|
createdAt: input.createdAt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -77,17 +74,23 @@ export function chatQueueFullNotice(): string {
|
|||||||
return `队列已满(最多 ${MAX_QUEUED_CHAT_TURNS} 条),请等当前回合结束后再发送`;
|
return `队列已满(最多 ${MAX_QUEUED_CHAT_TURNS} 条),请等当前回合结束后再发送`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 队列 chip 上显示的文字:单行、有长度上限。 */
|
/**
|
||||||
export function queuedChatTurnLabel(turn: QueuedChatTurn): string {
|
* 队列 chip 上显示的文字:与真实消息同一个派生(`directCodexContentToPromptText`),
|
||||||
const text = turn.prompt.trim().replace(/\s+/gu, ' ');
|
* 再压成单行并限长。
|
||||||
if (text) {
|
*
|
||||||
return text.length > 24 ? `${text.slice(0, 24)}…` : text;
|
* `assets` 与聊天消息渲染同源(`manifest.assets`)且必填:`@` 引用按显示名展开,chip
|
||||||
}
|
* 与消息正文逐字一致,不会露出 `@asset:…` 这种内部 id。
|
||||||
if (turn.attachments.length > 0) {
|
*/
|
||||||
return `附件 · ${turn.attachments[0]?.name ?? '未命名'}`;
|
export function queuedChatTurnLabel(
|
||||||
}
|
turn: QueuedChatTurn,
|
||||||
if (turn.references.length > 0) {
|
assets: readonly GameCreationAppAssetManifestEntry[],
|
||||||
return '素材引用';
|
): string {
|
||||||
}
|
const text = directCodexContentToPromptText(turn.userItem.content, assets)
|
||||||
return '未命名消息';
|
.trim()
|
||||||
|
.replace(/\s+/gu, ' ');
|
||||||
|
return text
|
||||||
|
? text.length > 24
|
||||||
|
? `${text.slice(0, 24)}…`
|
||||||
|
: text
|
||||||
|
: '未命名消息';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
import { resolveTauriInvoke } from '../../app/tauri';
|
import { resolveTauriInvoke } from '../../app/tauri';
|
||||||
import {
|
import type { DirectCodexUserContentPart } from './generated';
|
||||||
type ChatComposerDraft,
|
|
||||||
chatReferenceListKey,
|
|
||||||
} from './resourceReferences';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 「不再提醒」偏好存本机 localStorage,不进 manifest、不进后端。
|
* 「不再提醒」偏好存本机 localStorage,不进 manifest、不进后端。
|
||||||
@@ -61,8 +58,10 @@ export function writeChatPromptPolishReminderDisabled(disabled: boolean) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 草稿指纹:用于判断「本轮草稿」是否已经被润色或确认过。 */
|
/** 草稿指纹:用于判断「本轮草稿」是否已经被润色或确认过。 */
|
||||||
export function chatPromptDraftKey(draft: ChatComposerDraft) {
|
export function chatPromptDraftKey(
|
||||||
return `${draft.text}\u0000${chatReferenceListKey(draft.references)}`;
|
content: readonly DirectCodexUserContentPart[],
|
||||||
|
) {
|
||||||
|
return JSON.stringify(content);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -73,25 +72,27 @@ export function chatPromptDraftKey(draft: ChatComposerDraft) {
|
|||||||
* 4. 草稿不是以 `/` 开头的命令 —— 命令走直通路径,不参与提醒。
|
* 4. 草稿不是以 `/` 开头的命令 —— 命令走直通路径,不参与提醒。
|
||||||
*/
|
*/
|
||||||
export function shouldRemindChatPromptPolish({
|
export function shouldRemindChatPromptPolish({
|
||||||
draft,
|
content,
|
||||||
|
prompt,
|
||||||
acknowledgedDraftKey,
|
acknowledgedDraftKey,
|
||||||
reminderDisabled,
|
reminderDisabled,
|
||||||
}: {
|
}: {
|
||||||
draft: ChatComposerDraft;
|
content: readonly DirectCodexUserContentPart[];
|
||||||
|
prompt: string;
|
||||||
acknowledgedDraftKey: string | null;
|
acknowledgedDraftKey: string | null;
|
||||||
reminderDisabled: boolean;
|
reminderDisabled: boolean;
|
||||||
}) {
|
}) {
|
||||||
if (reminderDisabled) {
|
if (reminderDisabled) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const text = draft.text.trim();
|
const text = prompt.trim();
|
||||||
if (text.length < CHAT_PROMPT_POLISH_REMINDER_MIN_TEXT_LENGTH) {
|
if (text.length < CHAT_PROMPT_POLISH_REMINDER_MIN_TEXT_LENGTH) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (text.startsWith('/')) {
|
if (text.startsWith('/')) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return chatPromptDraftKey(draft) !== acknowledgedDraftKey;
|
return chatPromptDraftKey(content) !== acknowledgedDraftKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
|
export type DirectCodexUserAttachmentReferencePart = {
|
||||||
|
name: string;
|
||||||
|
mediaType: string;
|
||||||
|
size: number;
|
||||||
|
localPath: string;
|
||||||
|
status: string;
|
||||||
|
};
|
||||||
+6
-3
@@ -1,5 +1,5 @@
|
|||||||
// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
import type { DirectCodexUserAttachmentReferencePart } from './DirectCodexUserAttachmentReferencePart';
|
||||||
import type { DirectCodexUserRuntimeRegionPart } from './DirectCodexUserRuntimeRegionPart';
|
import type { DirectCodexUserRuntimeRegionPart } from './DirectCodexUserRuntimeRegionPart';
|
||||||
|
|
||||||
export type DirectCodexUserContentPart =
|
export type DirectCodexUserContentPart =
|
||||||
@@ -7,4 +7,7 @@ export type DirectCodexUserContentPart =
|
|||||||
| { type: 'agc_resource_reference'; resourceId: string }
|
| { type: 'agc_resource_reference'; resourceId: string }
|
||||||
| ({
|
| ({
|
||||||
type: 'agc_runtime_region_reference';
|
type: 'agc_runtime_region_reference';
|
||||||
} & DirectCodexUserRuntimeRegionPart);
|
} & DirectCodexUserRuntimeRegionPart)
|
||||||
|
| ({
|
||||||
|
type: 'agc_attachment_reference';
|
||||||
|
} & DirectCodexUserAttachmentReferencePart);
|
||||||
|
|||||||
+7
-3
@@ -1,5 +1,9 @@
|
|||||||
// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
import type { DirectCodexUserMessageItem } from './DirectCodexUserMessageItem';
|
import type { DirectCodexUserMessageItem } from './DirectCodexUserMessageItem';
|
||||||
|
|
||||||
export type DirectCodexUserItem = DirectCodexUserMessageItem;
|
/**
|
||||||
|
* DirectProject 本轮 user input 的唯一结构化入口。
|
||||||
|
*/
|
||||||
|
export type DirectCodexUserItem = {
|
||||||
|
type: 'message';
|
||||||
|
} & DirectCodexUserMessageItem;
|
||||||
|
|||||||
+4
@@ -0,0 +1,4 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
import type { DirectCodexUserItem } from './DirectCodexUserItem';
|
||||||
|
|
||||||
|
export type DirectCodexUserMessageEnvelope = { item: DirectCodexUserItem };
|
||||||
+2
-4
@@ -1,11 +1,9 @@
|
|||||||
// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
import type { DirectCodexUserContentPart } from './DirectCodexUserContentPart';
|
import type { DirectCodexUserContentPart } from './DirectCodexUserContentPart';
|
||||||
import type { DirectCodexUserRole } from './DirectCodexUserRole';
|
import type { DirectCodexUserRole } from './DirectCodexUserRole';
|
||||||
|
|
||||||
export type DirectCodexUserMessageItem = {
|
export type DirectCodexUserMessageItem = {
|
||||||
type: 'message';
|
|
||||||
role: DirectCodexUserRole;
|
role: DirectCodexUserRole;
|
||||||
content: DirectCodexUserContentPart[];
|
content: Array<DirectCodexUserContentPart>;
|
||||||
id: string;
|
id: string;
|
||||||
};
|
};
|
||||||
|
|||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
export type DirectCodexUserRole = 'user';
|
export type DirectCodexUserRole = 'user';
|
||||||
|
|||||||
+9
-9
@@ -1,13 +1,13 @@
|
|||||||
// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
export type DirectCodexUserRuntimeRegionPart = {
|
export type DirectCodexUserRuntimeRegionPart = {
|
||||||
label: string;
|
label: string;
|
||||||
runId?: string;
|
runId: string | null;
|
||||||
versionId?: string;
|
versionId: string | null;
|
||||||
elementTag?: string;
|
elementTag: string | null;
|
||||||
elementRole?: string;
|
elementRole: string | null;
|
||||||
text?: string;
|
text: string | null;
|
||||||
width?: number;
|
width: number | null;
|
||||||
height?: number;
|
height: number | null;
|
||||||
resourceIds: string[];
|
resourceIds: Array<string>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
export type { DirectCodexUserContentPart } from './DirectCodexUserContentPart';
|
export type { DirectCodexUserContentPart } from './DirectCodexUserContentPart';
|
||||||
|
export type { DirectCodexUserAttachmentReferencePart } from './DirectCodexUserAttachmentReferencePart';
|
||||||
export type { DirectCodexUserItem } from './DirectCodexUserItem';
|
export type { DirectCodexUserItem } from './DirectCodexUserItem';
|
||||||
export type { DirectCodexUserMessageItem } from './DirectCodexUserMessageItem';
|
export type { DirectCodexUserMessageItem } from './DirectCodexUserMessageItem';
|
||||||
export type { DirectCodexUserRole } from './DirectCodexUserRole';
|
export type { DirectCodexUserRole } from './DirectCodexUserRole';
|
||||||
export type { DirectCodexUserRuntimeRegionPart } from './DirectCodexUserRuntimeRegionPart';
|
export type { DirectCodexUserRuntimeRegionPart } from './DirectCodexUserRuntimeRegionPart';
|
||||||
|
export type { DirectCodexUserMessageEnvelope } from './DirectCodexUserMessageEnvelope';
|
||||||
export type { DirectThreadConsumeResult } from './DirectThreadConsumeResult';
|
export type { DirectThreadConsumeResult } from './DirectThreadConsumeResult';
|
||||||
export type { DirectThreadDeltaKind } from './DirectThreadDeltaKind';
|
export type { DirectThreadDeltaKind } from './DirectThreadDeltaKind';
|
||||||
export type { DirectThreadEvent } from './DirectThreadEvent';
|
export type { DirectThreadEvent } from './DirectThreadEvent';
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import {
|
|||||||
import type {
|
import type {
|
||||||
DirectCodexUserContentPart,
|
DirectCodexUserContentPart,
|
||||||
DirectCodexUserItem,
|
DirectCodexUserItem,
|
||||||
DirectCodexUserMessageItem,
|
|
||||||
} from './generated';
|
} from './generated';
|
||||||
|
|
||||||
export type ResourceReferenceSource =
|
export type ResourceReferenceSource =
|
||||||
@@ -52,8 +51,8 @@ export type ChatReference = ResourceReference | RuntimeRegionReference;
|
|||||||
export type ChatComposerDraft = {
|
export type ChatComposerDraft = {
|
||||||
text: string;
|
text: string;
|
||||||
references: ChatReference[];
|
references: ChatReference[];
|
||||||
/** Lexical 顺序对应的 canonical user content;仅由编辑器读回时提供。 */
|
/** Lexical 顺序对应的 canonical user content,是草稿的唯一结构化来源。 */
|
||||||
content?: DirectCodexUserContentPart[];
|
content: DirectCodexUserContentPart[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const RESOURCE_REFERENCE_INSERT_EVENT = 'agc-resource-reference-insert';
|
export const RESOURCE_REFERENCE_INSERT_EVENT = 'agc-resource-reference-insert';
|
||||||
@@ -94,22 +93,58 @@ export const EMPTY_CHAT_COMPOSER_DRAFT: ChatComposerDraft = {
|
|||||||
content: [],
|
content: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** canonical content → 可读文本;资源引用按当前 manifest 展开为显示名。 */
|
||||||
|
export function directCodexContentToPromptText(
|
||||||
|
content: readonly DirectCodexUserContentPart[],
|
||||||
|
assets: readonly GameCreationAppAssetManifestEntry[],
|
||||||
|
) {
|
||||||
|
const labels = new Map(
|
||||||
|
assets.map((asset) => [asset.id, resourceDisplayName(asset)]),
|
||||||
|
);
|
||||||
|
return content
|
||||||
|
.map((part) => {
|
||||||
|
if (part.type === 'input_text') return part.text;
|
||||||
|
if (part.type === 'agc_resource_reference') {
|
||||||
|
return `@${labels.get(part.resourceId) ?? part.resourceId}`;
|
||||||
|
}
|
||||||
|
if (part.type === 'agc_runtime_region_reference') {
|
||||||
|
return `@${part.label}`;
|
||||||
|
}
|
||||||
|
if (part.type === 'agc_attachment_reference') {
|
||||||
|
return `@${part.name}`;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
})
|
||||||
|
.join('')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 只在整条 content 上判定有效性;单个纯空白文本 part 合法。 */
|
||||||
|
export function hasMeaningfulDirectCodexContent(
|
||||||
|
content: readonly DirectCodexUserContentPart[],
|
||||||
|
) {
|
||||||
|
return content.some(
|
||||||
|
(part) => part.type !== 'input_text' || part.text.trim().length > 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function chatReferenceToContentPart(
|
export function chatReferenceToContentPart(
|
||||||
reference: ChatReference,
|
reference: ChatReference,
|
||||||
): DirectCodexUserContentPart {
|
): DirectCodexUserContentPart {
|
||||||
if (reference.type === 'resource') {
|
if (reference.type === 'resource') {
|
||||||
return { type: 'agc_resource_reference', resourceId: reference.resourceId };
|
return { type: 'agc_resource_reference', resourceId: reference.resourceId };
|
||||||
}
|
}
|
||||||
|
// 生成绑定里可选字段是 `T | null`(Rust `Option<T>` 会显式序列化成 null,不是省略键)。
|
||||||
return {
|
return {
|
||||||
type: 'agc_runtime_region_reference',
|
type: 'agc_runtime_region_reference',
|
||||||
label: reference.label,
|
label: reference.label,
|
||||||
runId: reference.runId,
|
runId: reference.runId ?? null,
|
||||||
versionId: reference.versionId,
|
versionId: reference.versionId ?? null,
|
||||||
elementTag: reference.elementTag,
|
elementTag: reference.elementTag ?? null,
|
||||||
elementRole: reference.elementRole,
|
elementRole: reference.elementRole ?? null,
|
||||||
text: reference.text,
|
text: reference.text ?? null,
|
||||||
width: reference.width,
|
width: reference.width ?? null,
|
||||||
height: reference.height,
|
height: reference.height ?? null,
|
||||||
resourceIds: reference.resourceIds,
|
resourceIds: reference.resourceIds,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -118,24 +153,22 @@ export function chatComposerDraftToDirectCodexUserItem(
|
|||||||
draft: ChatComposerDraft,
|
draft: ChatComposerDraft,
|
||||||
id: string,
|
id: string,
|
||||||
): DirectCodexUserItem {
|
): DirectCodexUserItem {
|
||||||
const content = draft.content?.length
|
|
||||||
? draft.content
|
|
||||||
: draft.references.length > 0
|
|
||||||
? [
|
|
||||||
...(draft.text
|
|
||||||
? [{ type: 'input_text' as const, text: draft.text }]
|
|
||||||
: []),
|
|
||||||
...draft.references.map(chatReferenceToContentPart),
|
|
||||||
]
|
|
||||||
: draft.text
|
|
||||||
? [{ type: 'input_text' as const, text: draft.text }]
|
|
||||||
: [];
|
|
||||||
return {
|
return {
|
||||||
type: 'message',
|
type: 'message',
|
||||||
role: 'user',
|
role: 'user',
|
||||||
content,
|
content: draft.content,
|
||||||
id,
|
id,
|
||||||
} satisfies DirectCodexUserMessageItem;
|
} satisfies DirectCodexUserItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function directCodexUserItemFromContent(
|
||||||
|
content: readonly DirectCodexUserContentPart[],
|
||||||
|
id: string,
|
||||||
|
): DirectCodexUserItem {
|
||||||
|
return chatComposerDraftToDirectCodexUserItem(
|
||||||
|
{ text: '', references: [], content: [...content] },
|
||||||
|
id,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resourceDisplayName(asset: GameCreationAppAssetManifestEntry) {
|
export function resourceDisplayName(asset: GameCreationAppAssetManifestEntry) {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user