Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5161c6eafb | |||
| 4abeb3a7f4 | |||
| fcb00f8a4a | |||
| 939deafb01 | |||
| e5f40df216 | |||
| 1b8d4d3b94 | |||
| 2283dd6fbf | |||
| 8a763b61f7 | |||
| 1c8d818ee0 | |||
| ea141c2ca1 | |||
| 20f0762f26 | |||
| 3e745a1c9e | |||
| 15ad3815d8 | |||
| aa44f08f24 | |||
| 249ed3a6b2 | |||
| f731dad73c |
@@ -2840,8 +2840,22 @@ impl CodexAppServerConnection {
|
|||||||
codex_app_server_text_prompt(&request)
|
codex_app_server_text_prompt(&request)
|
||||||
.map_err(platform_llm::LlmError::InvalidRequest)?
|
.map_err(platform_llm::LlmError::InvalidRequest)?
|
||||||
};
|
};
|
||||||
let input =
|
let input = if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
|
||||||
codex_app_server_turn_input(&request, &prompt, &self.inner.workspace_path).await?;
|
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 _direct_tool_bridge_turn_guard =
|
let _direct_tool_bridge_turn_guard =
|
||||||
if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
|
if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
|
||||||
Some(
|
Some(
|
||||||
|
|||||||
@@ -11,6 +11,6 @@ pub(crate) use model::{
|
|||||||
};
|
};
|
||||||
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::{
|
||||||
direct_codex_user_item_to_prompt, direct_codex_user_item_to_response_item,
|
direct_codex_user_item_to_codex_turn_input, direct_codex_user_item_to_prompt,
|
||||||
direct_codex_user_item_to_wire_input,
|
direct_codex_user_item_to_response_item, direct_codex_user_item_to_wire_input,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ pub(crate) enum DirectCodexUserContentPart {
|
|||||||
InputText { text: String },
|
InputText { text: String },
|
||||||
#[serde(rename = "agc_resource_reference")]
|
#[serde(rename = "agc_resource_reference")]
|
||||||
AgcResourceReference { resource_id: String },
|
AgcResourceReference { resource_id: String },
|
||||||
|
#[serde(rename = "agc_skill_reference")]
|
||||||
|
AgcSkillReference { name: 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.
|
/// Uploaded project attachment kept inline in canonical content.
|
||||||
|
|||||||
+22
-59
@@ -12,7 +12,7 @@ pub(crate) const MAX_DIRECT_CODEX_REFERENCES: usize = 32;
|
|||||||
pub(crate) fn validate_direct_codex_user_item(
|
pub(crate) fn validate_direct_codex_user_item(
|
||||||
root: &Path,
|
root: &Path,
|
||||||
item: &DirectCodexUserItem,
|
item: &DirectCodexUserItem,
|
||||||
) -> Result<(), String> {
|
) -> Result<GameCreationAppManifest, String> {
|
||||||
let DirectCodexUserItem::Message(message) = item;
|
let DirectCodexUserItem::Message(message) = item;
|
||||||
if !matches!(message.role, DirectCodexUserRole::User) {
|
if !matches!(message.role, DirectCodexUserRole::User) {
|
||||||
return Err("DirectProject 只接受 user message item".to_string());
|
return Err("DirectProject 只接受 user message item".to_string());
|
||||||
@@ -20,20 +20,36 @@ 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());
|
||||||
}
|
}
|
||||||
// 有效输入只判一整条 content:单个纯空白 `input_text` 是合法 part —— 编辑器里的段落
|
if message.content.is_empty() {
|
||||||
// 分隔、软换行与 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;
|
||||||
for part in &message.content {
|
for part in &message.content {
|
||||||
match part {
|
match part {
|
||||||
DirectCodexUserContentPart::InputText { .. } => {}
|
DirectCodexUserContentPart::InputText { text } => {
|
||||||
|
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)?;
|
||||||
}
|
}
|
||||||
|
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) => {
|
DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => {
|
||||||
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)?;
|
||||||
@@ -55,15 +71,7 @@ pub(crate) fn validate_direct_codex_user_item(
|
|||||||
if reference_count > MAX_DIRECT_CODEX_REFERENCES {
|
if reference_count > MAX_DIRECT_CODEX_REFERENCES {
|
||||||
return Err(format!("一次最多引用 {MAX_DIRECT_CODEX_REFERENCES} 个素材"));
|
return Err(format!("一次最多引用 {MAX_DIRECT_CODEX_REFERENCES} 个素材"));
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(manifest)
|
||||||
}
|
|
||||||
|
|
||||||
/// 整条 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(
|
||||||
@@ -104,48 +112,3 @@ fn validate_runtime_region_reference(
|
|||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::content_has_meaningful_input;
|
|
||||||
use crate::agent::direct_codex_user_item::model::DirectCodexUserContentPart;
|
|
||||||
|
|
||||||
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(),
|
|
||||||
},
|
|
||||||
]));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
use super::model::{DirectCodexUserContentPart, DirectCodexUserItem};
|
use super::model::{
|
||||||
|
DirectCodexUserContentPart, DirectCodexUserItem, DirectCodexUserRuntimeRegionPart,
|
||||||
|
};
|
||||||
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, GameCreationAppManifest,
|
||||||
|
};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
@@ -51,6 +55,47 @@ fn direct_codex_user_item_to_response_content(
|
|||||||
.collect()
|
.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` 可接受的文本数组。
|
/// 将 canonical user item 转为 app-server `turn/start.input` 可接受的文本数组。
|
||||||
/// AGC 私有 part 只在这里投影为安全摘要,canonical item 本身不被修改。
|
/// AGC 私有 part 只在这里投影为安全摘要,canonical item 本身不被修改。
|
||||||
pub(crate) fn direct_codex_user_item_to_wire_input(
|
pub(crate) fn direct_codex_user_item_to_wire_input(
|
||||||
@@ -65,40 +110,13 @@ pub(crate) fn direct_codex_user_item_to_wire_input(
|
|||||||
let text = match part {
|
let text = match part {
|
||||||
DirectCodexUserContentPart::InputText { text } => text.clone(),
|
DirectCodexUserContentPart::InputText { text } => text.clone(),
|
||||||
DirectCodexUserContentPart::AgcResourceReference { resource_id } => {
|
DirectCodexUserContentPart::AgcResourceReference { resource_id } => {
|
||||||
let asset = manifest
|
resource_reference_summary(&manifest, resource_id)?
|
||||||
.assets
|
}
|
||||||
.iter()
|
DirectCodexUserContentPart::AgcSkillReference { name } => {
|
||||||
.find(|asset| asset.id == resource_id.trim())
|
format!("${}", name.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) => {
|
DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => {
|
||||||
let resources = reference
|
runtime_region_summary(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
|
|
||||||
}
|
}
|
||||||
DirectCodexUserContentPart::AgcAttachmentReference(reference) => {
|
DirectCodexUserContentPart::AgcAttachmentReference(reference) => {
|
||||||
let mut summary = format!(
|
let mut summary = format!(
|
||||||
@@ -120,6 +138,66 @@ pub(crate) fn direct_codex_user_item_to_wire_input(
|
|||||||
Ok(Value::Array(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(
|
pub(crate) fn direct_codex_user_item_to_prompt(
|
||||||
root: &Path,
|
root: &Path,
|
||||||
item: &DirectCodexUserItem,
|
item: &DirectCodexUserItem,
|
||||||
@@ -144,8 +222,7 @@ 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, direct_codex_user_item_to_wire_input};
|
use super::direct_codex_user_item_to_response_item;
|
||||||
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;
|
||||||
|
|
||||||
@@ -211,48 +288,4 @@ mod tests {
|
|||||||
assert!(content[0]["text"].as_str().unwrap().contains("先看"));
|
assert!(content[0]["text"].as_str().unwrap().contains("先看"));
|
||||||
assert!(content[1]["text"].as_str().unwrap().contains("notes.txt"));
|
assert!(content[1]["text"].as_str().unwrap().contains("notes.txt"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[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}");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use serde::Deserialize;
|
use serde::{Deserialize, Serialize};
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use std::borrow::Cow;
|
use std::borrow::Cow;
|
||||||
use std::collections::BTreeSet;
|
use std::collections::BTreeSet;
|
||||||
@@ -121,6 +121,13 @@ struct AgcSkillManifestEntry {
|
|||||||
sha256: String,
|
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 {
|
fn is_safe_skill_relative_path(value: &str) -> bool {
|
||||||
let path = Path::new(value);
|
let path = Path::new(value);
|
||||||
!value.is_empty()
|
!value.is_empty()
|
||||||
@@ -234,6 +241,21 @@ pub(crate) fn agc_skill_pack_fingerprint() -> Result<String, String> {
|
|||||||
Ok(format!("{:x}", Sha256::digest(canonical_manifest.as_ref())))
|
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> {
|
pub(crate) fn render_agc_skill_pack_index() -> Result<String, String> {
|
||||||
let manifest = validated_skill_pack_manifest()?;
|
let manifest = validated_skill_pack_manifest()?;
|
||||||
let mut lines = vec![format!(
|
let mut lines = vec![format!(
|
||||||
@@ -328,6 +350,19 @@ 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]
|
#[test]
|
||||||
fn skill_content_digest_is_stable_across_lf_and_crlf() {
|
fn skill_content_digest_is_stable_across_lf_and_crlf() {
|
||||||
fn digest(bytes: &[u8]) -> String {
|
fn digest(bytes: &[u8]) -> String {
|
||||||
|
|||||||
@@ -2655,6 +2655,7 @@ fn main() {
|
|||||||
pick_client_extension_file,
|
pick_client_extension_file,
|
||||||
pick_client_extension_directory,
|
pick_client_extension_directory,
|
||||||
list_client_extensions,
|
list_client_extensions,
|
||||||
|
list_agc_skill_catalog,
|
||||||
import_client_extension,
|
import_client_extension,
|
||||||
set_client_extension_enabled,
|
set_client_extension_enabled,
|
||||||
rename_client_extension,
|
rename_client_extension,
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ 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,
|
||||||
@@ -193,12 +192,9 @@ 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) {
|
||||||
@@ -215,11 +211,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, assets)}
|
{queuedChatTurnLabel(turn)}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={`取消排队消息 ${queuedChatTurnLabel(turn, assets)}`}
|
aria-label={`取消排队消息 ${queuedChatTurnLabel(turn)}`}
|
||||||
title="取消这条排队消息"
|
title="取消这条排队消息"
|
||||||
onClick={() => onCancel(turn.id)}
|
onClick={() => onCancel(turn.id)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -889,7 +889,6 @@ export function ProjectSupervisorView({
|
|||||||
>
|
>
|
||||||
<ComposerTurnQueue
|
<ComposerTurnQueue
|
||||||
turns={directCodex ? queuedTurns : []}
|
turns={directCodex ? queuedTurns : []}
|
||||||
assets={chatProjectAssets}
|
|
||||||
onCancel={(id) => onCancelQueuedTurn?.(id)}
|
onCancel={(id) => onCancelQueuedTurn?.(id)}
|
||||||
/>
|
/>
|
||||||
<ComposerPendingAttachments
|
<ComposerPendingAttachments
|
||||||
|
|||||||
+19
-8
@@ -4,6 +4,16 @@ import { X } from 'lucide-react';
|
|||||||
|
|
||||||
import type { ChatReference } from './resourceReferences';
|
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({
|
export function ResourceReferenceChip({
|
||||||
reference,
|
reference,
|
||||||
nodeKey,
|
nodeKey,
|
||||||
@@ -21,18 +31,19 @@ export function ResourceReferenceChip({
|
|||||||
data-runtime-region-reference={
|
data-runtime-region-reference={
|
||||||
reference.type === 'runtime-region' ? 'true' : undefined
|
reference.type === 'runtime-region' ? 'true' : undefined
|
||||||
}
|
}
|
||||||
contentEditable={false}
|
data-skill-reference-name={
|
||||||
title={
|
reference.type === 'skill' ? reference.name : undefined
|
||||||
reference.type === 'resource'
|
|
||||||
? `${reference.label} · ${reference.kind}`
|
|
||||||
: `${reference.label} · 运行区域`
|
|
||||||
}
|
}
|
||||||
|
contentEditable={false}
|
||||||
|
title={chipTitle(reference)}
|
||||||
>
|
>
|
||||||
<span aria-hidden="true">@</span>
|
<span aria-hidden="true">{reference.type === 'skill' ? '$' : '@'}</span>
|
||||||
<span className="resource-reference-chip-label">{reference.label}</span>
|
<span className="resource-reference-chip-label">
|
||||||
|
{reference.type === 'skill' ? reference.name : reference.label}
|
||||||
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={`移除引用 ${reference.label}`}
|
aria-label={`移除引用 ${reference.type === 'skill' ? reference.name : reference.label}`}
|
||||||
onMouseDown={(event) => event.preventDefault()}
|
onMouseDown={(event) => event.preventDefault()}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
editor.update(() => {
|
editor.update(() => {
|
||||||
|
|||||||
+235
-103
@@ -93,6 +93,7 @@ import {
|
|||||||
resourceReferenceMatchesTagSelection,
|
resourceReferenceMatchesTagSelection,
|
||||||
type ResourceReferenceScope,
|
type ResourceReferenceScope,
|
||||||
resourceReferenceTagLibrary,
|
resourceReferenceTagLibrary,
|
||||||
|
type SkillReference,
|
||||||
} from './resourceReferences';
|
} from './resourceReferences';
|
||||||
import { usePromptPolish } from './usePromptPolish';
|
import { usePromptPolish } from './usePromptPolish';
|
||||||
|
|
||||||
@@ -101,6 +102,7 @@ type ResourceReferenceInputProps = {
|
|||||||
onEditorStateChange?: (editorState: EditorState) => void;
|
onEditorStateChange?: (editorState: EditorState) => void;
|
||||||
initialContent?: DirectCodexUserContentPart[];
|
initialContent?: DirectCodexUserContentPart[];
|
||||||
assets: GameCreationAppAssetManifestEntry[];
|
assets: GameCreationAppAssetManifestEntry[];
|
||||||
|
skills?: SkillReference[];
|
||||||
projectPath: string;
|
projectPath: string;
|
||||||
/**
|
/**
|
||||||
* `@` 面板「当前版本素材」页签使用的版本 id。
|
* `@` 面板「当前版本素材」页签使用的版本 id。
|
||||||
@@ -174,27 +176,37 @@ class ResourceMentionOption extends MenuOption {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
class SkillMentionOption extends MenuOption {
|
||||||
* 编辑器节点 → canonical content part,**原样透传**:段落分隔(root 子节点之间补的
|
skill: SkillReference;
|
||||||
* `\n`)、软换行、chip 后的分隔空格都各自成 part,不做空白过滤、不与相邻 part 合并。
|
|
||||||
* 有效输入只在整条 content 上判定(`hasMeaningfulDirectCodexContent` 与 Rust
|
constructor(skill: SkillReference) {
|
||||||
* `validate_direct_codex_user_item` 同口径),前端不替用户改写他输入了什么。
|
super(skill.name);
|
||||||
*/
|
this.skill = skill;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendInputText(content: DirectCodexUserContentPart[], text: string) {
|
||||||
|
const previous = content[content.length - 1];
|
||||||
|
if (previous?.type === 'input_text') {
|
||||||
|
previous.text += text;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (text.trim()) {
|
||||||
|
content.push({ type: 'input_text', text });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function collectDraftParts(
|
function collectDraftParts(
|
||||||
node: LexicalNode,
|
node: LexicalNode,
|
||||||
references: ChatReference[],
|
references: ChatReference[],
|
||||||
content: DirectCodexUserContentPart[],
|
content: DirectCodexUserContentPart[],
|
||||||
) {
|
) {
|
||||||
if ($isTextNode(node)) {
|
if ($isTextNode(node)) {
|
||||||
const text = node.getTextContent();
|
appendInputText(content, node.getTextContent());
|
||||||
// Lexical 不会留下空 TextNode;这只防「空串 part」落进 app-server 输入。
|
|
||||||
if (text) {
|
|
||||||
content.push({ type: 'input_text', text });
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if ($isLineBreakNode(node)) {
|
if ($isLineBreakNode(node)) {
|
||||||
content.push({ type: 'input_text', text: '\n' });
|
appendInputText(content, '\n');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if ($isResourceReferenceNode(node)) {
|
if ($isResourceReferenceNode(node)) {
|
||||||
@@ -205,7 +217,7 @@ function collectDraftParts(
|
|||||||
if ($isElementNode(node)) {
|
if ($isElementNode(node)) {
|
||||||
node.getChildren().forEach((child, index) => {
|
node.getChildren().forEach((child, index) => {
|
||||||
if (index > 0 && node.getType() === 'root') {
|
if (index > 0 && node.getType() === 'root') {
|
||||||
content.push({ type: 'input_text', text: '\n' });
|
appendInputText(content, '\n');
|
||||||
}
|
}
|
||||||
collectDraftParts(child, references, content);
|
collectDraftParts(child, references, content);
|
||||||
});
|
});
|
||||||
@@ -274,7 +286,7 @@ function findDraftMentionToken(line: string, token: string, from: number) {
|
|||||||
* 把「文本 + 引用列表」这对外部表示切成编辑区要写的内容。
|
* 把「文本 + 引用列表」这对外部表示切成编辑区要写的内容。
|
||||||
*
|
*
|
||||||
* 不变量:写入编辑器后,编辑器读回的草稿就是唯一当前值。`collectDraftParts`
|
* 不变量:写入编辑器后,编辑器读回的草稿就是唯一当前值。`collectDraftParts`
|
||||||
* 会把每个 chip 读成一段 `@显示名` 文本,所以 chip 只能内联嵌在文本里同名 token 的位置上,
|
* 会把每个 chip 读成一段 `@显示名` / `$skill-name` 文本,所以 chip 只能内联嵌在文本里同名 token 的位置上,
|
||||||
* 不能另起一段堆在末尾;编辑器本身是唯一 authority,不再通过 props 比较后反复重建。
|
* 不能另起一段堆在末尾;编辑器本身是唯一 authority,不再通过 props 比较后反复重建。
|
||||||
*
|
*
|
||||||
* 文本里已经没有对应 token 的引用(例如 AI 润色整体改写了文本)内联补到最后一段末尾,
|
* 文本里已经没有对应 token 的引用(例如 AI 润色整体改写了文本)内联补到最后一段末尾,
|
||||||
@@ -286,7 +298,8 @@ function buildDraftSegments(
|
|||||||
): DraftBuildSegment[][] {
|
): DraftBuildSegment[][] {
|
||||||
const pending = references.map((reference) => ({
|
const pending = references.map((reference) => ({
|
||||||
reference,
|
reference,
|
||||||
token: `@${reference.label}`,
|
token:
|
||||||
|
reference.type === 'skill' ? `$${reference.name}` : `@${reference.label}`,
|
||||||
used: false,
|
used: false,
|
||||||
}));
|
}));
|
||||||
const lines: DraftBuildSegment[][] = [];
|
const lines: DraftBuildSegment[][] = [];
|
||||||
@@ -359,6 +372,9 @@ function referenceFromContentPart(
|
|||||||
const asset = assetsById.get(part.resourceId);
|
const asset = assetsById.get(part.resourceId);
|
||||||
return asset ? resourceReferenceFromAsset(asset, 'asset-picker') : null;
|
return asset ? resourceReferenceFromAsset(asset, 'asset-picker') : null;
|
||||||
}
|
}
|
||||||
|
if (part.type === 'agc_skill_reference') {
|
||||||
|
return { type: 'skill', name: part.name };
|
||||||
|
}
|
||||||
if (part.type === 'agc_runtime_region_reference') {
|
if (part.type === 'agc_runtime_region_reference') {
|
||||||
return {
|
return {
|
||||||
type: 'runtime-region',
|
type: 'runtime-region',
|
||||||
@@ -500,6 +516,7 @@ function ResourceReferenceEditor({
|
|||||||
onEditorStateChange,
|
onEditorStateChange,
|
||||||
initialContent,
|
initialContent,
|
||||||
assets,
|
assets,
|
||||||
|
skills = [],
|
||||||
projectPath,
|
projectPath,
|
||||||
activeVersionId = null,
|
activeVersionId = null,
|
||||||
versions,
|
versions,
|
||||||
@@ -516,14 +533,17 @@ function ResourceReferenceEditor({
|
|||||||
const [editor] = useLexicalComposerContext();
|
const [editor] = useLexicalComposerContext();
|
||||||
const skipInitialDraftChangeRef = useRef(false);
|
const skipInitialDraftChangeRef = useRef(false);
|
||||||
const [query, setQuery] = useState<string | null>(null);
|
const [query, setQuery] = useState<string | null>(null);
|
||||||
|
const [skillQuery, setSkillQuery] = useState<string | null>(null);
|
||||||
|
const [builtinSkills, setBuiltinSkills] = useState<SkillReference[]>([]);
|
||||||
|
const [clientSkills, setClientSkills] = useState<SkillReference[]>([]);
|
||||||
const [pickerOpen, setPickerOpen] = useState(false);
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
// Enter 提交要避开候选浮层。用 ref 让命令处理器读到最新状态,不必因为
|
// Enter 提交要避开候选浮层。用 ref 让命令处理器读到最新状态,不必因为
|
||||||
// query / pickerOpen 变化反复重注册 HIGH 优先级命令。
|
// query / pickerOpen 变化反复重注册 HIGH 优先级命令。
|
||||||
const mentionMenuOpenRef = useRef(false);
|
const mentionMenuOpenRef = useRef(false);
|
||||||
const pickerVisibleRef = useRef(false);
|
const pickerVisibleRef = useRef(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
mentionMenuOpenRef.current = query !== null;
|
mentionMenuOpenRef.current = query !== null || skillQuery !== null;
|
||||||
}, [query]);
|
}, [query, skillQuery]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
pickerVisibleRef.current = pickerOpen;
|
pickerVisibleRef.current = pickerOpen;
|
||||||
}, [pickerOpen]);
|
}, [pickerOpen]);
|
||||||
@@ -537,6 +557,55 @@ function ResourceReferenceEditor({
|
|||||||
bottom: number;
|
bottom: number;
|
||||||
width: number;
|
width: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const invoke = resolveTauriInvoke();
|
||||||
|
if (!invoke) return;
|
||||||
|
let cancelled = false;
|
||||||
|
void invoke<Array<{ name: string; description: string }>>(
|
||||||
|
'list_agc_skill_catalog',
|
||||||
|
)
|
||||||
|
.then((items) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setBuiltinSkills(
|
||||||
|
items.map((item) => ({
|
||||||
|
type: 'skill' as const,
|
||||||
|
name: item.name,
|
||||||
|
description: item.description,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setBuiltinSkills([]);
|
||||||
|
});
|
||||||
|
void invoke<
|
||||||
|
Array<{
|
||||||
|
name: string;
|
||||||
|
extensionType: string;
|
||||||
|
enabled: boolean;
|
||||||
|
status: string;
|
||||||
|
}>
|
||||||
|
>('list_client_extensions')
|
||||||
|
.then((items) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setClientSkills(
|
||||||
|
items
|
||||||
|
.filter(
|
||||||
|
(item) =>
|
||||||
|
item.extensionType === 'skill' &&
|
||||||
|
item.enabled &&
|
||||||
|
item.status === 'enabled',
|
||||||
|
)
|
||||||
|
.map((item) => ({ type: 'skill' as const, name: item.name })),
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setClientSkills([]);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const assetsContentSignature = assetsSignature(assets);
|
const assetsContentSignature = assetsSignature(assets);
|
||||||
const versionsContentSignature = iterationsSignature(versions);
|
const versionsContentSignature = iterationsSignature(versions);
|
||||||
const assetsById = useMemo(
|
const assetsById = useMemo(
|
||||||
@@ -601,11 +670,35 @@ function ResourceReferenceEditor({
|
|||||||
.map((reference) => new ResourceMentionOption(reference));
|
.map((reference) => new ResourceMentionOption(reference));
|
||||||
}, [assetReferences, query]);
|
}, [assetReferences, query]);
|
||||||
|
|
||||||
|
const skillOptions = useMemo(() => {
|
||||||
|
if (skillQuery === null) return [];
|
||||||
|
const normalized = skillQuery.trim().toLowerCase();
|
||||||
|
const allSkills = [...builtinSkills, ...clientSkills, ...skills];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return allSkills
|
||||||
|
.filter((skill) => {
|
||||||
|
if (seen.has(skill.name)) return false;
|
||||||
|
seen.add(skill.name);
|
||||||
|
return (
|
||||||
|
!normalized ||
|
||||||
|
skill.name.toLowerCase().includes(normalized) ||
|
||||||
|
skill.description?.toLowerCase().includes(normalized)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.slice(0, 8)
|
||||||
|
.map((skill) => new SkillMentionOption(skill));
|
||||||
|
}, [builtinSkills, clientSkills, skillQuery, skills]);
|
||||||
|
|
||||||
const triggerFn = useBasicTypeaheadTriggerMatch('@', {
|
const triggerFn = useBasicTypeaheadTriggerMatch('@', {
|
||||||
minLength: 0,
|
minLength: 0,
|
||||||
maxLength: 64,
|
maxLength: 64,
|
||||||
allowWhitespace: false,
|
allowWhitespace: false,
|
||||||
});
|
});
|
||||||
|
const skillTriggerFn = useBasicTypeaheadTriggerMatch('$', {
|
||||||
|
minLength: 0,
|
||||||
|
maxLength: 64,
|
||||||
|
allowWhitespace: false,
|
||||||
|
});
|
||||||
|
|
||||||
const insertReferences = useCallback(
|
const insertReferences = useCallback(
|
||||||
(nextReferences: ChatReference[]) => {
|
(nextReferences: ChatReference[]) => {
|
||||||
@@ -773,30 +866,47 @@ function ResourceReferenceEditor({
|
|||||||
[editor, pickerOpen],
|
[editor, pickerOpen],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const insertReferenceNode = useCallback(
|
||||||
|
(
|
||||||
|
reference: ChatReference,
|
||||||
|
textNodeContainingQuery: TextNode | null,
|
||||||
|
closeMenu: () => void,
|
||||||
|
) => {
|
||||||
|
textNodeContainingQuery?.remove();
|
||||||
|
const selection = $getSelection();
|
||||||
|
const node = $createResourceReferenceNode(reference);
|
||||||
|
if ($isRangeSelection(selection)) {
|
||||||
|
selection.insertNodes([node, $createTextNode(' ')]);
|
||||||
|
} else {
|
||||||
|
const paragraph = $createParagraphNode();
|
||||||
|
paragraph.append(node, $createTextNode(' '));
|
||||||
|
$getRoot().append(paragraph);
|
||||||
|
}
|
||||||
|
closeMenu();
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const handleSelectMention = useCallback(
|
const handleSelectMention = useCallback(
|
||||||
(
|
(
|
||||||
option: ResourceMentionOption,
|
option: ResourceMentionOption,
|
||||||
textNodeContainingQuery: TextNode | null,
|
textNodeContainingQuery: TextNode | null,
|
||||||
closeMenu: () => void,
|
closeMenu: () => void,
|
||||||
) => {
|
) => {
|
||||||
textNodeContainingQuery?.remove();
|
insertReferenceNode(option.reference, textNodeContainingQuery, closeMenu);
|
||||||
const selection = $getSelection();
|
|
||||||
if ($isRangeSelection(selection)) {
|
|
||||||
selection.insertNodes([
|
|
||||||
$createResourceReferenceNode(option.reference),
|
|
||||||
$createTextNode(' '),
|
|
||||||
]);
|
|
||||||
} else {
|
|
||||||
const paragraph = $createParagraphNode();
|
|
||||||
paragraph.append(
|
|
||||||
$createResourceReferenceNode(option.reference),
|
|
||||||
$createTextNode(' '),
|
|
||||||
);
|
|
||||||
$getRoot().append(paragraph);
|
|
||||||
}
|
|
||||||
closeMenu();
|
|
||||||
},
|
},
|
||||||
[],
|
[insertReferenceNode],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSelectSkill = useCallback(
|
||||||
|
(
|
||||||
|
option: SkillMentionOption,
|
||||||
|
textNodeContainingQuery: TextNode | null,
|
||||||
|
closeMenu: () => void,
|
||||||
|
) => {
|
||||||
|
insertReferenceNode(option.skill, textNodeContainingQuery, closeMenu);
|
||||||
|
},
|
||||||
|
[insertReferenceNode],
|
||||||
);
|
);
|
||||||
|
|
||||||
// —— C8 AI 润色与发送前提醒 ——
|
// —— C8 AI 润色与发送前提醒 ——
|
||||||
@@ -934,74 +1044,81 @@ function ResourceReferenceEditor({
|
|||||||
acknowledgedDraftKeyRef.current = null;
|
acknowledgedDraftKeyRef.current = null;
|
||||||
}, [resetPromptPolish]);
|
}, [resetPromptPolish]);
|
||||||
|
|
||||||
const renderMentionMenu: MenuRenderFn<ResourceMentionOption> = useCallback(
|
const renderMentionMenu: MenuRenderFn<
|
||||||
(_anchorElementRef, itemProps) => {
|
ResourceMentionOption | SkillMentionOption
|
||||||
const inputRect = rootRef.current?.getBoundingClientRect();
|
> = useCallback((_anchorElementRef, itemProps) => {
|
||||||
if (!inputRect || itemProps.options.length === 0) {
|
const inputRect = rootRef.current?.getBoundingClientRect();
|
||||||
return null;
|
if (!inputRect || itemProps.options.length === 0) {
|
||||||
}
|
return null;
|
||||||
const viewportPadding = 12;
|
}
|
||||||
const menuWidth = Math.min(
|
const viewportPadding = 12;
|
||||||
Math.max(280, inputRect.width),
|
const menuWidth = Math.min(
|
||||||
Math.min(420, window.innerWidth - viewportPadding * 2),
|
Math.max(280, inputRect.width),
|
||||||
);
|
Math.min(420, window.innerWidth - viewportPadding * 2),
|
||||||
const left = Math.min(
|
);
|
||||||
Math.max(viewportPadding, inputRect.left),
|
const left = Math.min(
|
||||||
Math.max(
|
Math.max(viewportPadding, inputRect.left),
|
||||||
viewportPadding,
|
Math.max(
|
||||||
window.innerWidth - menuWidth - viewportPadding,
|
viewportPadding,
|
||||||
),
|
window.innerWidth - menuWidth - viewportPadding,
|
||||||
);
|
),
|
||||||
const availableAbove = Math.max(0, inputRect.top - viewportPadding - 8);
|
);
|
||||||
const availableBelow = Math.max(
|
const availableAbove = Math.max(0, inputRect.top - viewportPadding - 8);
|
||||||
0,
|
const availableBelow = Math.max(
|
||||||
window.innerHeight - inputRect.bottom - viewportPadding - 8,
|
0,
|
||||||
);
|
window.innerHeight - inputRect.bottom - viewportPadding - 8,
|
||||||
const openAbove = availableBelow < 160 && availableAbove > availableBelow;
|
);
|
||||||
const maxHeight = Math.max(
|
const openAbove = availableBelow < 160 && availableAbove > availableBelow;
|
||||||
120,
|
const maxHeight = Math.max(
|
||||||
Math.min(240, openAbove ? availableAbove : availableBelow),
|
120,
|
||||||
);
|
Math.min(240, openAbove ? availableAbove : availableBelow),
|
||||||
const top = openAbove
|
);
|
||||||
? Math.max(viewportPadding, inputRect.top - maxHeight - 8)
|
const top = openAbove
|
||||||
: inputRect.bottom + 8;
|
? Math.max(viewportPadding, inputRect.top - maxHeight - 8)
|
||||||
return createPortal(
|
: inputRect.bottom + 8;
|
||||||
<div
|
return createPortal(
|
||||||
className="resource-reference-menu"
|
<div
|
||||||
role="listbox"
|
className="resource-reference-menu"
|
||||||
aria-label="候选素材"
|
role="listbox"
|
||||||
style={{
|
aria-label="候选引用"
|
||||||
position: 'fixed',
|
style={{
|
||||||
top: `${top}px`,
|
position: 'fixed',
|
||||||
left: `${left}px`,
|
top: `${top}px`,
|
||||||
width: `${menuWidth}px`,
|
left: `${left}px`,
|
||||||
maxHeight: `${maxHeight}px`,
|
width: `${menuWidth}px`,
|
||||||
}}
|
maxHeight: `${maxHeight}px`,
|
||||||
>
|
}}
|
||||||
{itemProps.options.map((option, index) => (
|
>
|
||||||
<button
|
{itemProps.options.map((option, index) => (
|
||||||
type="button"
|
<button
|
||||||
role="option"
|
type="button"
|
||||||
key={option.key}
|
role="option"
|
||||||
ref={(element) => option.setRefElement(element)}
|
key={option.key}
|
||||||
aria-selected={itemProps.selectedIndex === index}
|
ref={(element) => option.setRefElement(element)}
|
||||||
className={
|
aria-selected={itemProps.selectedIndex === index}
|
||||||
itemProps.selectedIndex === index ? 'is-active' : undefined
|
className={
|
||||||
}
|
itemProps.selectedIndex === index ? 'is-active' : undefined
|
||||||
onMouseEnter={() => itemProps.setHighlightedIndex(index)}
|
}
|
||||||
onMouseDown={(event) => event.preventDefault()}
|
onMouseEnter={() => itemProps.setHighlightedIndex(index)}
|
||||||
onClick={() => itemProps.selectOptionAndCleanUp(option)}
|
onMouseDown={(event) => event.preventDefault()}
|
||||||
>
|
onClick={() => itemProps.selectOptionAndCleanUp(option)}
|
||||||
<span>{option.reference.label}</span>
|
>
|
||||||
<small>{option.reference.kind}</small>
|
<span>
|
||||||
</button>
|
{'reference' in option
|
||||||
))}
|
? `@${option.reference.label}`
|
||||||
</div>,
|
: `$${option.skill.name}`}
|
||||||
document.body,
|
</span>
|
||||||
);
|
<small>
|
||||||
},
|
{'reference' in option
|
||||||
[rootRef],
|
? option.reference.kind
|
||||||
);
|
: (option.skill.description ?? 'Skill')}
|
||||||
|
</small>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}, [rootRef]);
|
||||||
|
|
||||||
const pickerReferences = useMemo(() => {
|
const pickerReferences = useMemo(() => {
|
||||||
return scopeReferences.filter((reference) => {
|
return scopeReferences.filter((reference) => {
|
||||||
@@ -1118,7 +1235,22 @@ function ResourceReferenceEditor({
|
|||||||
onSelectOption={(option, textNode, closeMenu) =>
|
onSelectOption={(option, textNode, closeMenu) =>
|
||||||
handleSelectMention(option, textNode, closeMenu)
|
handleSelectMention(option, textNode, closeMenu)
|
||||||
}
|
}
|
||||||
menuRenderFn={renderMentionMenu}
|
menuRenderFn={
|
||||||
|
renderMentionMenu as unknown as MenuRenderFn<ResourceMentionOption>
|
||||||
|
}
|
||||||
|
anchorClassName="resource-reference-menu-anchor"
|
||||||
|
preselectFirstItem
|
||||||
|
/>
|
||||||
|
<LexicalTypeaheadMenuPlugin<SkillMentionOption>
|
||||||
|
options={skillOptions}
|
||||||
|
triggerFn={skillTriggerFn}
|
||||||
|
onQueryChange={setSkillQuery}
|
||||||
|
onSelectOption={(option, textNode, closeMenu) =>
|
||||||
|
handleSelectSkill(option, textNode, closeMenu)
|
||||||
|
}
|
||||||
|
menuRenderFn={
|
||||||
|
renderMentionMenu as unknown as MenuRenderFn<SkillMentionOption>
|
||||||
|
}
|
||||||
anchorClassName="resource-reference-menu-anchor"
|
anchorClassName="resource-reference-menu-anchor"
|
||||||
preselectFirstItem
|
preselectFirstItem
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
* 回合运行中用户再次发送时,消息进入 FIFO 队列而不是被丢弃;当前回合结束后按入队顺序
|
* 回合运行中用户再次发送时,消息进入 FIFO 队列而不是被丢弃;当前回合结束后按入队顺序
|
||||||
* 依次发出。队列项能在输入盒上方单独取消。这里只放与 React 无关的纯逻辑,便于单测。
|
* 依次发出。队列项能在输入盒上方单独取消。这里只放与 React 无关的纯逻辑,便于单测。
|
||||||
*/
|
*/
|
||||||
import type { GameCreationAppAssetManifestEntry } from '../../../../../packages/shared/src/contracts/gameCreationApp';
|
|
||||||
import type { DirectCodexUserItem } from './generated';
|
import type { DirectCodexUserItem } from './generated';
|
||||||
import { directCodexContentToPromptText } from './resourceReferences';
|
import { directCodexContentToPromptText } from './resourceReferences';
|
||||||
|
|
||||||
@@ -74,18 +73,9 @@ export function chatQueueFullNotice(): string {
|
|||||||
return `队列已满(最多 ${MAX_QUEUED_CHAT_TURNS} 条),请等当前回合结束后再发送`;
|
return `队列已满(最多 ${MAX_QUEUED_CHAT_TURNS} 条),请等当前回合结束后再发送`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 队列 chip 上显示的文字:单行、有长度上限。 */
|
||||||
* 队列 chip 上显示的文字:与真实消息同一个派生(`directCodexContentToPromptText`),
|
export function queuedChatTurnLabel(turn: QueuedChatTurn): string {
|
||||||
* 再压成单行并限长。
|
const text = directCodexContentToPromptText(turn.userItem.content)
|
||||||
*
|
|
||||||
* `assets` 与聊天消息渲染同源(`manifest.assets`)且必填:`@` 引用按显示名展开,chip
|
|
||||||
* 与消息正文逐字一致,不会露出 `@asset:…` 这种内部 id。
|
|
||||||
*/
|
|
||||||
export function queuedChatTurnLabel(
|
|
||||||
turn: QueuedChatTurn,
|
|
||||||
assets: readonly GameCreationAppAssetManifestEntry[],
|
|
||||||
): string {
|
|
||||||
const text = directCodexContentToPromptText(turn.userItem.content, assets)
|
|
||||||
.trim()
|
.trim()
|
||||||
.replace(/\s+/gu, ' ');
|
.replace(/\s+/gu, ' ');
|
||||||
if (text) {
|
if (text) {
|
||||||
|
|||||||
+1
@@ -5,6 +5,7 @@ import type { DirectCodexUserRuntimeRegionPart } from './DirectCodexUserRuntimeR
|
|||||||
export type DirectCodexUserContentPart =
|
export type DirectCodexUserContentPart =
|
||||||
| { type: 'input_text'; text: string }
|
| { type: 'input_text'; text: string }
|
||||||
| { type: 'agc_resource_reference'; resourceId: string }
|
| { type: 'agc_resource_reference'; resourceId: string }
|
||||||
|
| { type: 'agc_skill_reference'; name: string }
|
||||||
| ({
|
| ({
|
||||||
type: 'agc_runtime_region_reference';
|
type: 'agc_runtime_region_reference';
|
||||||
} & DirectCodexUserRuntimeRegionPart)
|
} & DirectCodexUserRuntimeRegionPart)
|
||||||
|
|||||||
@@ -46,7 +46,16 @@ export type RuntimeRegionReference = {
|
|||||||
source: 'runtime-picker';
|
source: 'runtime-picker';
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ChatReference = ResourceReference | RuntimeRegionReference;
|
export type SkillReference = {
|
||||||
|
type: 'skill';
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ChatReference =
|
||||||
|
| ResourceReference
|
||||||
|
| RuntimeRegionReference
|
||||||
|
| SkillReference;
|
||||||
|
|
||||||
export type ChatComposerDraft = {
|
export type ChatComposerDraft = {
|
||||||
/** Lexical 顺序对应的 canonical user content;这是唯一草稿真相。 */
|
/** Lexical 顺序对应的 canonical user content;这是唯一草稿真相。 */
|
||||||
@@ -89,17 +98,9 @@ export const EMPTY_CHAT_COMPOSER_DRAFT: ChatComposerDraft = {
|
|||||||
content: [],
|
content: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* canonical content → 可读文本:**消息正文、队列 chip、润色判据、出站 prompt 只有这一个口径**。
|
|
||||||
*
|
|
||||||
* `assets` 是当前项目的 manifest 且必填:`agc_resource_reference` 按 `@显示名` 展开,与用户
|
|
||||||
* 在编辑器里看到、以及聊天消息里渲染出来的字面量逐字一致。没有默认值 —— 漏传素材清单会
|
|
||||||
* 静默退化成 `@内部 id`(用户看到的文案与真正落盘的 prompt 分叉)。只有素材已不在清单里时
|
|
||||||
* 才回落到 `resourceId`,那是「引用指向已消失的素材」的兜底而不是常态。
|
|
||||||
*/
|
|
||||||
export function directCodexContentToPromptText(
|
export function directCodexContentToPromptText(
|
||||||
content: readonly DirectCodexUserContentPart[],
|
content: readonly DirectCodexUserContentPart[],
|
||||||
assets: readonly GameCreationAppAssetManifestEntry[],
|
assets: readonly GameCreationAppAssetManifestEntry[] = [],
|
||||||
) {
|
) {
|
||||||
const labels = new Map(
|
const labels = new Map(
|
||||||
assets.map((asset) => [asset.id, resourceDisplayName(asset)]),
|
assets.map((asset) => [asset.id, resourceDisplayName(asset)]),
|
||||||
@@ -110,6 +111,9 @@ export function directCodexContentToPromptText(
|
|||||||
if (part.type === 'agc_resource_reference') {
|
if (part.type === 'agc_resource_reference') {
|
||||||
return `@${labels.get(part.resourceId) ?? part.resourceId}`;
|
return `@${labels.get(part.resourceId) ?? part.resourceId}`;
|
||||||
}
|
}
|
||||||
|
if (part.type === 'agc_skill_reference') {
|
||||||
|
return `$${part.name}`;
|
||||||
|
}
|
||||||
if (part.type === 'agc_runtime_region_reference') {
|
if (part.type === 'agc_runtime_region_reference') {
|
||||||
return `@${part.label}`;
|
return `@${part.label}`;
|
||||||
}
|
}
|
||||||
@@ -133,6 +137,9 @@ export function chatReferenceToContentPart(
|
|||||||
if (reference.type === 'resource') {
|
if (reference.type === 'resource') {
|
||||||
return { type: 'agc_resource_reference', resourceId: reference.resourceId };
|
return { type: 'agc_resource_reference', resourceId: reference.resourceId };
|
||||||
}
|
}
|
||||||
|
if (reference.type === 'skill') {
|
||||||
|
return { type: 'agc_skill_reference', name: reference.name };
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
type: 'agc_runtime_region_reference',
|
type: 'agc_runtime_region_reference',
|
||||||
label: reference.label,
|
label: reference.label,
|
||||||
@@ -379,6 +386,9 @@ function chatReferenceKey(reference: ChatReference) {
|
|||||||
if (reference.type === 'resource') {
|
if (reference.type === 'resource') {
|
||||||
return `resource:${reference.resourceId}:${reference.source}`;
|
return `resource:${reference.resourceId}:${reference.source}`;
|
||||||
}
|
}
|
||||||
|
if (reference.type === 'skill') {
|
||||||
|
return `skill:${reference.name}`;
|
||||||
|
}
|
||||||
return `runtime-region:${runtimeRegionReferenceDiscriminators(reference)}`;
|
return `runtime-region:${runtimeRegionReferenceDiscriminators(reference)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -388,11 +398,15 @@ function chatReferenceKey(reference: ChatReference) {
|
|||||||
*/
|
*/
|
||||||
export function chatReferenceListKey(references: ChatReference[]) {
|
export function chatReferenceListKey(references: ChatReference[]) {
|
||||||
return references
|
return references
|
||||||
.map((reference) =>
|
.map((reference) => {
|
||||||
reference.type === 'resource'
|
if (reference.type === 'resource') {
|
||||||
? `resource:${reference.resourceId}:${reference.source}:${reference.label}`
|
return `resource:${reference.resourceId}:${reference.source}:${reference.label}`;
|
||||||
: `runtime-region:${runtimeRegionReferenceDiscriminators(reference)}`,
|
}
|
||||||
)
|
if (reference.type === 'skill') {
|
||||||
|
return `skill:${reference.name}`;
|
||||||
|
}
|
||||||
|
return `runtime-region:${runtimeRegionReferenceDiscriminators(reference)}`;
|
||||||
|
})
|
||||||
.join('\u0001');
|
.join('\u0001');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6489,51 +6489,41 @@ export default function ProjectDevelopmentView({
|
|||||||
*
|
*
|
||||||
* 与共享 composer 的 `resetFailedDialogStatus` 同口径:提示词变了就不再是上一次
|
* 与共享 composer 的 `resetFailedDialogStatus` 同口径:提示词变了就不再是上一次
|
||||||
* 失败的那份请求,状态回到 idle、错误清空——同时提交侧会按新提示词重铸请求身份。
|
* 失败的那份请求,状态回到 idle、错误清空——同时提交侧会按新提示词重铸请求身份。
|
||||||
* 比较用的草稿文本必须和输入区自己读草稿的口径一致(都带 `manifest.assets`):
|
|
||||||
* 少传素材清单会把 chip 读成 `@内部 id`,与真正写进面板的 `@显示名` 永远不等,
|
|
||||||
* 于是每次改写都再 `replaceText` 一次。
|
|
||||||
*/
|
*/
|
||||||
const applyResourceQuickEditPrompt = useCallback(
|
const applyResourceQuickEditPrompt = useCallback((text: string) => {
|
||||||
(text: string) => {
|
const currentDraft = quickEditPromptInputRef.current?.getDraft();
|
||||||
const currentDraft = quickEditPromptInputRef.current?.getDraft();
|
if (
|
||||||
if (
|
currentDraft &&
|
||||||
currentDraft &&
|
directCodexContentToPromptText(currentDraft.content) !== text
|
||||||
directCodexContentToPromptText(
|
) {
|
||||||
currentDraft.content,
|
quickEditPromptInputRef.current?.replaceText(text);
|
||||||
manifest.assets,
|
}
|
||||||
) !== text
|
setQuickEditPanel((current) =>
|
||||||
) {
|
current
|
||||||
quickEditPromptInputRef.current?.replaceText(text);
|
? {
|
||||||
}
|
...current,
|
||||||
setQuickEditPanel((current) =>
|
prompt: text,
|
||||||
current
|
status: current.status === 'failed' ? 'idle' : current.status,
|
||||||
? {
|
errorMessage:
|
||||||
...current,
|
current.status === 'failed' ? undefined : current.errorMessage,
|
||||||
prompt: text,
|
}
|
||||||
status: current.status === 'failed' ? 'idle' : current.status,
|
: current,
|
||||||
errorMessage:
|
);
|
||||||
current.status === 'failed' ? undefined : current.errorMessage,
|
}, []);
|
||||||
}
|
|
||||||
: current,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[manifest.assets],
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 快速编辑提示词输入区的整体草稿(文本 + `@` 引用)。
|
* 快速编辑提示词输入区的整体草稿(文本 + `@` 引用)。
|
||||||
*
|
*
|
||||||
* 文本仍走 `applyResourceQuickEditPrompt`(失败态重置口径不变),引用单独收下来供
|
* 文本仍走 `applyResourceQuickEditPrompt`(失败态重置口径不变),引用单独收下来供
|
||||||
* 输入区回填 chip。提交时的出站 payload 就是这份文本:与聊天 `@` 同源、同字面量,
|
* 输入区回填 chip。提交时的出站 payload 就是这份文本:与聊天 `@` 同源、同字面量。
|
||||||
* 所以 `@` 引用必须带上 `manifest.assets` 按显示名展开。
|
|
||||||
*/
|
*/
|
||||||
const applyResourceQuickEditDraft = useCallback(
|
const applyResourceQuickEditDraft = useCallback(
|
||||||
(draft: ChatComposerDraft) => {
|
(draft: ChatComposerDraft) => {
|
||||||
applyResourceQuickEditPrompt(
|
applyResourceQuickEditPrompt(
|
||||||
directCodexContentToPromptText(draft.content, manifest.assets),
|
directCodexContentToPromptText(draft.content),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[applyResourceQuickEditPrompt, manifest.assets],
|
[applyResourceQuickEditPrompt],
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -196,35 +196,6 @@ function queuedTurn(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function registerChatComposerControlTests() {
|
export function registerChatComposerControlTests() {
|
||||||
it('队列 chip 的 @ 引用按 manifest 显示名展开,不露出内部 resourceId', () => {
|
|
||||||
const turn = createQueuedChatTurn({
|
|
||||||
id: 'turn-ref',
|
|
||||||
clientTurnId: 'client-ref',
|
|
||||||
userItem: directCodexUserItemFromContent(
|
|
||||||
[
|
|
||||||
{ type: 'input_text', text: '用这张图改一下' },
|
|
||||||
{ type: 'agc_resource_reference', resourceId: 'asset:hero' },
|
|
||||||
],
|
|
||||||
'client-ref:user',
|
|
||||||
),
|
|
||||||
createdAt: 1,
|
|
||||||
});
|
|
||||||
const manifestAssets = [
|
|
||||||
{
|
|
||||||
id: 'asset:hero',
|
|
||||||
kind: 'character',
|
|
||||||
mediaType: 'image/png',
|
|
||||||
localPath: 'assets/hero.png',
|
|
||||||
source: { kind: 'uploaded' as const },
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// chip 文案与聊天输入区同口径:用户看到的是 `@显示名`,不是 `@内部 id`。
|
|
||||||
expect(queuedChatTurnLabel(turn, manifestAssets)).toBe(
|
|
||||||
'用这张图改一下@hero',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps queued chat turns in FIFO order and drops only the cancelled one', () => {
|
it('keeps queued chat turns in FIFO order and drops only the cancelled one', () => {
|
||||||
const first = queuedTurn('turn-1', 'client-1', '第一条', 1);
|
const first = queuedTurn('turn-1', 'client-1', '第一条', 1);
|
||||||
const second = queuedTurn('turn-2', 'client-2', '第二条', 2);
|
const second = queuedTurn('turn-2', 'client-2', '第二条', 2);
|
||||||
@@ -239,10 +210,8 @@ export function registerChatComposerControlTests() {
|
|||||||
// FIFO:先入先出,不丢、不乱序。
|
// FIFO:先入先出,不丢、不乱序。
|
||||||
const firstOut = dequeueChatTurn(queue);
|
const firstOut = dequeueChatTurn(queue);
|
||||||
expect(firstOut.next?.clientTurnId).toBe('client-1');
|
expect(firstOut.next?.clientTurnId).toBe('client-1');
|
||||||
expect(firstOut.next && queuedChatTurnLabel(firstOut.next, [])).toBe(
|
expect(firstOut.next && queuedChatTurnLabel(firstOut.next)).toBe('第一条');
|
||||||
'第一条',
|
expect(firstOut.rest.map((turn) => queuedChatTurnLabel(turn))).toEqual([
|
||||||
);
|
|
||||||
expect(firstOut.rest.map((turn) => queuedChatTurnLabel(turn, []))).toEqual([
|
|
||||||
'第二条',
|
'第二条',
|
||||||
'第三条',
|
'第三条',
|
||||||
]);
|
]);
|
||||||
@@ -250,7 +219,7 @@ export function registerChatComposerControlTests() {
|
|||||||
// 单条取消只移除那一条,顺序不变。
|
// 单条取消只移除那一条,顺序不变。
|
||||||
expect(
|
expect(
|
||||||
removeQueuedChatTurn(firstOut.rest, 'turn-2').map((turn) =>
|
removeQueuedChatTurn(firstOut.rest, 'turn-2').map((turn) =>
|
||||||
queuedChatTurnLabel(turn, []),
|
queuedChatTurnLabel(turn),
|
||||||
),
|
),
|
||||||
).toEqual(['第三条']);
|
).toEqual(['第三条']);
|
||||||
expect(removeQueuedChatTurn(firstOut.rest, 'turn-missing')).toHaveLength(2);
|
expect(removeQueuedChatTurn(firstOut.rest, 'turn-missing')).toHaveLength(2);
|
||||||
|
|||||||
@@ -9342,7 +9342,7 @@ export function registerProjectSupervisorSurfaceTests() {
|
|||||||
expect(fireEvent.keyDown(composer, { key: 'Enter' })).toBe(false);
|
expect(fireEvent.keyDown(composer, { key: 'Enter' })).toBe(false);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
// canonical content 原样保留编辑器内容:上面的 Shift+Enter 与组合态 Enter
|
// canonical content 原样保留编辑器内容:上面的 Shift+Enter 与组合态 Enter
|
||||||
// 在编辑器里各插入一个真实换行,提交时不能被悄悄丢掉,也不与相邻文本合并。
|
// 在编辑器里各插入一个真实换行,提交时不能被悄悄丢掉。
|
||||||
expect(invoke).toHaveBeenCalledWith(
|
expect(invoke).toHaveBeenCalledWith(
|
||||||
'chat_with_game_creator_direct_codex',
|
'chat_with_game_creator_direct_codex',
|
||||||
{
|
{
|
||||||
@@ -9353,11 +9353,7 @@ export function registerProjectSupervisorSurfaceTests() {
|
|||||||
id: expect.stringMatching(/^direct-codex:[A-Za-z0-9-]+:user$/),
|
id: expect.stringMatching(/^direct-codex:[A-Za-z0-9-]+:user$/),
|
||||||
type: 'message',
|
type: 'message',
|
||||||
role: 'user',
|
role: 'user',
|
||||||
content: [
|
content: [{ type: 'input_text', text: '修改玩家移动脚本\n\n' }],
|
||||||
{ type: 'input_text', text: '修改玩家移动脚本' },
|
|
||||||
{ type: 'input_text', text: '\n' },
|
|
||||||
{ type: 'input_text', text: '\n' },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -85,9 +85,9 @@ async function settleComposer() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 草稿展示文本:canonical content 是唯一真相;这里刻意不传 manifest,引用展开成 `@id`。 */
|
/** 草稿展示文本:canonical content 是唯一真相,引用按稳定 id 展开成 `@id`。 */
|
||||||
function draftText(draft: ChatComposerDraft | undefined) {
|
function draftText(draft: ChatComposerDraft | undefined) {
|
||||||
return directCodexContentToPromptText(draft?.content ?? [], []);
|
return directCodexContentToPromptText(draft?.content ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 草稿里的资源引用 id,按 content 顺序。 */
|
/** 草稿里的资源引用 id,按 content 顺序。 */
|
||||||
@@ -169,6 +169,34 @@ async function insertAssetThroughPicker(ariaLabel: string, optionName: RegExp) {
|
|||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
|
|
||||||
describe('ResourceReferenceInput', () => {
|
describe('ResourceReferenceInput', () => {
|
||||||
|
test('从 Tauri command 读取内置 Skill catalog', async () => {
|
||||||
|
const invoke = vi.fn(async (command: string) => {
|
||||||
|
if (command === 'list_agc_skill_catalog') {
|
||||||
|
return [{ name: 'agc-test-skill', description: '测试 Skill' }];
|
||||||
|
}
|
||||||
|
if (command === 'list_client_extensions') return [];
|
||||||
|
throw new Error(`unexpected invoke ${command}`);
|
||||||
|
});
|
||||||
|
window.__TAURI__ = { core: { invoke: invoke as never } };
|
||||||
|
|
||||||
|
try {
|
||||||
|
render(
|
||||||
|
<ResourceReferenceInput
|
||||||
|
onChange={vi.fn()}
|
||||||
|
assets={assets}
|
||||||
|
projectPath="C:/project"
|
||||||
|
ariaLabel="聊天"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(invoke).toHaveBeenCalledWith('list_agc_skill_catalog');
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
delete window.__TAURI__;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('运行画面引用的判别指纹带上了绑定素材、版本、元素角色与尺寸', () => {
|
test('运行画面引用的判别指纹带上了绑定素材、版本、元素角色与尺寸', () => {
|
||||||
const base: RuntimeRegionReference = {
|
const base: RuntimeRegionReference = {
|
||||||
type: 'runtime-region',
|
type: 'runtime-region',
|
||||||
@@ -251,51 +279,6 @@ describe('ResourceReferenceInput', () => {
|
|||||||
expect(screen.getByRole('button', { name: '模拟润色' })).not.toBeNull();
|
expect(screen.getByRole('button', { name: '模拟润色' })).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('引用后面的段落分隔原样落进 content:读回的 prompt 与编辑器分段逐字一致', async () => {
|
|
||||||
const onChange = vi.fn<(draft: ChatComposerDraft) => void>();
|
|
||||||
const reference = resourceReferenceFromAsset(assets[0]!, 'asset-picker');
|
|
||||||
function Controlled() {
|
|
||||||
const composerRef = createRef<ResourceReferenceInputHandle>();
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
|
||||||
composerRef.current?.replaceText('@hero\n把这一版改成夜景')
|
|
||||||
}
|
|
||||||
>
|
|
||||||
模拟润色
|
|
||||||
</button>
|
|
||||||
<ResourceReferenceInput
|
|
||||||
ref={composerRef}
|
|
||||||
initialContent={[chatReferenceToContentPart(reference)]}
|
|
||||||
onChange={onChange}
|
|
||||||
assets={assets}
|
|
||||||
projectPath="C:/project"
|
|
||||||
ariaLabel="聊天"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
render(<Controlled />);
|
|
||||||
await settleComposer();
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: '模拟润色' }));
|
|
||||||
await settleComposer();
|
|
||||||
await settleComposer();
|
|
||||||
|
|
||||||
const draft = onChange.mock.calls.at(-1)?.[0];
|
|
||||||
// chip 与后一段各自成段:段落分隔原样留在 canonical content 里(投影不做空白过滤,
|
|
||||||
// 也不与相邻 part 合并)。丢掉它读回的 prompt 会粘成 `@hero把这一版改成夜景`,
|
|
||||||
// 并原样进 agent、队列文案与润色判据。
|
|
||||||
expect(draft?.content).toEqual([
|
|
||||||
chatReferenceToContentPart(reference),
|
|
||||||
{ type: 'input_text', text: '\n' },
|
|
||||||
{ type: 'input_text', text: '把这一版改成夜景' },
|
|
||||||
]);
|
|
||||||
expect(draftText(draft)).toBe('@hero\n把这一版改成夜景');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('引用浮层打开时 Enter 不提交表单,关掉后恢复提交', async () => {
|
test('引用浮层打开时 Enter 不提交表单,关掉后恢复提交', async () => {
|
||||||
const onSubmit = vi.fn();
|
const onSubmit = vi.fn();
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
@@ -361,21 +344,9 @@ describe('ResourceReferenceInput', () => {
|
|||||||
expect(onChange).toHaveBeenCalled();
|
expect(onChange).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
const draft = onChange.mock.calls.at(-1)?.[0];
|
const draft = onChange.mock.calls.at(-1)?.[0];
|
||||||
// canonical content 是编辑器内容的逐字投影:picker 在每个 chip 后插入的分隔空格
|
// canonical content 只承载有意义的 part:chip 之间的分隔空格是纯 UI 排版,
|
||||||
// 也原样进内容,所以派生文本是 `@hero @enemy`(引用本身仍由稳定 resourceId 表达)。
|
// 引用本身由稳定 resourceId 表达。空格也不进内容——Rust 会拒绝纯空白 input_text。
|
||||||
const heroPart = chatReferenceToContentPart(
|
expect(draftText(draft)).toBe('@hero@enemy');
|
||||||
resourceReferenceFromAsset(assets[0]!, 'asset-picker'),
|
|
||||||
);
|
|
||||||
const enemyPart = chatReferenceToContentPart(
|
|
||||||
resourceReferenceFromAsset(assets[1]!, 'asset-picker'),
|
|
||||||
);
|
|
||||||
expect(draft?.content).toEqual([
|
|
||||||
heroPart,
|
|
||||||
{ type: 'input_text', text: ' ' },
|
|
||||||
enemyPart,
|
|
||||||
{ type: 'input_text', text: ' ' },
|
|
||||||
]);
|
|
||||||
expect(draftText(draft)).toBe('@hero @enemy');
|
|
||||||
expect(draftResourceIds(draft)).toEqual(['hero', 'enemy']);
|
expect(draftResourceIds(draft)).toEqual(['hero', 'enemy']);
|
||||||
expect(
|
expect(
|
||||||
document.querySelector('[data-resource-reference-id="hero"]'),
|
document.querySelector('[data-resource-reference-id="hero"]'),
|
||||||
@@ -836,15 +807,13 @@ describe('ResourceReferenceInput', () => {
|
|||||||
await user.click(screen.getByRole('button', { name: '插入引用' }));
|
await user.click(screen.getByRole('button', { name: '插入引用' }));
|
||||||
await settleComposer();
|
await settleComposer();
|
||||||
|
|
||||||
// 恢复的草稿文本 + picker 插入的 chip 与它后面的分隔空格,逐字就是编辑器里的内容。
|
|
||||||
expect(
|
expect(
|
||||||
onChange.mock.calls
|
onChange.mock.calls
|
||||||
.at(-1)?.[0]
|
.at(-1)?.[0]
|
||||||
.content.filter((part) => part.type === 'input_text'),
|
.content.filter((part) => part.type === 'input_text')
|
||||||
).toEqual([
|
.map((part) => (part.type === 'input_text' ? part.text : ''))
|
||||||
{ type: 'input_text', text: '恢复出来的草稿' },
|
.join(''),
|
||||||
{ type: 'input_text', text: ' ' },
|
).toBe('恢复出来的草稿');
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('标签库按 manifest 标签派生:计数只算候选、排序稳定、多标签取交集', () => {
|
test('标签库按 manifest 标签派生:计数只算候选、排序稳定、多标签取交集', () => {
|
||||||
@@ -1004,7 +973,6 @@ describe('ResourceReferenceInput', () => {
|
|||||||
// 「快速编辑」不允许出现第二种引用格式。
|
// 「快速编辑」不允许出现第二种引用格式。
|
||||||
expect(chatDraft?.content).toEqual([
|
expect(chatDraft?.content).toEqual([
|
||||||
{ type: 'agc_resource_reference', resourceId: 'hero' },
|
{ type: 'agc_resource_reference', resourceId: 'hero' },
|
||||||
{ type: 'input_text', text: ' ' },
|
|
||||||
]);
|
]);
|
||||||
expect(quickEditDraft).toEqual(chatDraft);
|
expect(quickEditDraft).toEqual(chatDraft);
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -96,14 +96,11 @@ describe('DirectProject user Response item', () => {
|
|||||||
|
|
||||||
it('展示用文本由 content 派生,引用按 @ 显示名展开', () => {
|
it('展示用文本由 content 派生,引用按 @ 显示名展开', () => {
|
||||||
expect(
|
expect(
|
||||||
directCodexContentToPromptText(
|
directCodexContentToPromptText([
|
||||||
[
|
{ type: 'input_text', text: '用 ' },
|
||||||
{ type: 'input_text', text: '用 ' },
|
{ type: 'agc_resource_reference', resourceId: 'asset-hero' },
|
||||||
{ type: 'agc_resource_reference', resourceId: 'asset-hero' },
|
{ type: 'input_text', text: ' 做主视觉' },
|
||||||
{ type: 'input_text', text: ' 做主视觉' },
|
]),
|
||||||
],
|
|
||||||
[],
|
|
||||||
),
|
|
||||||
).toBe('用 @asset-hero 做主视觉');
|
).toBe('用 @asset-hero 做主视觉');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# 【实施计划】DirectProject Skill 提及输入提示
|
||||||
|
|
||||||
|
| 字段 | 值 |
|
||||||
|
| --- | --- |
|
||||||
|
| Milestone | `docs/project-memory/plans/【里程碑】DirectProject Skill提及输入提示-2026-09-15.md` |
|
||||||
|
| Status | in-progress |
|
||||||
|
| Owner | Codex |
|
||||||
|
|
||||||
|
## 代码边界
|
||||||
|
|
||||||
|
- 前端:`features/project-workspace/ResourceReferenceInput.tsx`、`ResourceReferenceNode.tsx`、`resourceReferences.ts`、生成绑定及聊天入口透传。
|
||||||
|
- Rust:`agent/direct_codex_user_item/model.rs`、`validation.rs`、`wire.rs`、`codex_app_server/mod.rs` 与对应测试。
|
||||||
|
- 文档:父规范与本里程碑/实施计划。
|
||||||
|
|
||||||
|
## 小切片顺序
|
||||||
|
|
||||||
|
1. 先扩展前端 Skill catalog/节点/草稿 content,保持素材行为不变并补前端测试。
|
||||||
|
2. 扩展 canonical Rust part 与 ts-rs 绑定,补序列化和失败校验测试。
|
||||||
|
3. 接通 Codex wire `type: skill` 转换和受控路径解析,补历史/重放测试。
|
||||||
|
4. 完成入口透传、定向验证和文档证据;每个切片单独中文提交。
|
||||||
|
|
||||||
|
## 验证与回滚
|
||||||
|
|
||||||
|
- `npm --prefix apps/ai-game-creator-shell run typecheck`
|
||||||
|
- 相关 Vitest 与 `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml direct_codex`
|
||||||
|
- `npm run check:encoding`、`npm run check:doc-index`、`git diff --check`
|
||||||
|
- 每个切片只改计划列出的文件;若 Codex wire 协议或 Skill catalog 来源不确定,停在该切片并先更新规范。
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# 【里程碑】DirectProject Skill 提及输入提示
|
||||||
|
|
||||||
|
| 字段 | 值 |
|
||||||
|
| --- | --- |
|
||||||
|
| Version | 1.0 |
|
||||||
|
| Status | in-progress |
|
||||||
|
| Date | 2026-09-15 |
|
||||||
|
| Parent Spec | `docs/【功能说明】AGC聊天素材引用-2026-09-08.md` |
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
在现有 Lexical `ResourceReferenceInput` 中增加 Codex 风格 `$skill-name` 提及。用户选择 Skill 后,编辑器保留与文本相对顺序一致的原子节点;DirectProject canonical user item 保存稳定 Skill 名称,Rust 只允许当前已启用且 app-server 已发现的 Skill,并在 `turn/start` 转换为 Codex 原生 `type: "skill"` 输入项。
|
||||||
|
|
||||||
|
## 范围
|
||||||
|
|
||||||
|
- Skill 候选数据从当前 DirectProject 的已启用 Skill catalog 派生。
|
||||||
|
- `$` typeahead 菜单、键盘选择、鼠标选择、Esc/Enter 交互与现有 `@` 菜单一致。
|
||||||
|
- Skill inline 节点和 `content[]` 顺序恢复。
|
||||||
|
- canonical `agc_skill_reference` part 的 ts-rs 类型、Rust 校验、历史写入与 Codex wire 转换。
|
||||||
|
- 现有素材、运行区域、assistant、附件和工具 activity 行为保持不变。
|
||||||
|
|
||||||
|
## 不在范围内
|
||||||
|
|
||||||
|
- Skill 导入、启用、禁用、重命名和 app-server `skills/list` 生命周期改造。
|
||||||
|
- 普通自然语言自动分类 Skill;只支持显式 `$skill-name` 选择。
|
||||||
|
- Skill 正文预加载、Skill 内容编辑或新的权限/工具能力。
|
||||||
|
- SpacetimeDB、HTTP API 和 assistant item 协议变更。
|
||||||
|
|
||||||
|
## 验收标准
|
||||||
|
|
||||||
|
- [ ] 输入 `$` 可显示并过滤可用 Skill,候选项显示名称和描述。
|
||||||
|
- [ ] 选择 Skill 后插入原子 `$name` chip,文本与素材/运行区域的相对顺序保持不变。
|
||||||
|
- [ ] canonical user item 只保存 Skill 稳定名称,不保存正文、凭据或宿主私密路径。
|
||||||
|
- [ ] Rust 拒绝未知、禁用、未发现或名称非法的 Skill;失败时不写历史、不启动回合。
|
||||||
|
- [ ] 合法 Skill 在 Codex wire input 中生成 `type: "skill"`、`name`、受控 `path`,顺序与 canonical content 一致。
|
||||||
|
- [ ] 无 Skill 的旧消息、素材引用和标准 `response_item` 读取行为不变。
|
||||||
|
|
||||||
|
## 证据要求
|
||||||
|
|
||||||
|
- 前端:Lexical 草稿顺序、候选过滤、chip 原子性与 `$`/`@` 共存测试。
|
||||||
|
- Rust:模型序列化、Skill catalog 校验、wire 转换、失败关闭和历史重放测试。
|
||||||
|
- 运行时:DirectProject app-server smoke(环境可用时)。
|
||||||
|
- 门禁:相关 Vitest、AGC Rust 定向测试、`npm run check:encoding`、`npm run check:doc-index`、`git diff --check`。
|
||||||
@@ -38,14 +38,12 @@
|
|||||||
|
|
||||||
## 实现结论
|
## 实现结论
|
||||||
|
|
||||||
- 前端不再做任何空白过滤:Lexical 投影层原样透传编辑器节点,段落分隔符(root 子节点之间补的 `\n`)、软换行、chip 后的分隔空格都各自成 part,既不丢弃也不与相邻 part 合并 —— 前端不替用户改写他输入的内容。
|
- 唯一的前端空白过滤留在 Lexical 投影层:`agc_attachment`/`input_text` 之外的纯空白文本不作为 content part,因为 Rust `validate_direct_codex_user_item` 会拒绝空 `input_text`。转换函数不再重复过滤。
|
||||||
- 有效输入只判整条 content:有一段非空白文本或任何一个非文本 part 就算有效输入,单个纯空白 `input_text` 合法。前端 `hasMeaningfulDirectCodexContent` 与 Rust `validate_direct_codex_user_item`(`content_has_meaningful_input`)同口径,Rust 侧不再逐个 part 拒绝空文本;`wire.rs` 的「不能转换为空 prompt」只作兜底。
|
- 显示文本、队列 chip 文案、草稿持久化和润色判据统一由 `directCodexContentToPromptText(content, assets)` 从 content 派生,不再维护并行的 `text` 字段。
|
||||||
- 显示文本、队列 chip 文案、草稿持久化和润色判据统一由 `directCodexContentToPromptText(content, assets)` 从 content 派生,不再维护并行的 `text` 字段;`assets`(当前项目 manifest)必填,`agc_resource_reference` 按 `@显示名` 展开,消息正文与队列 chip 因此逐字一致。
|
|
||||||
- 需要文本草稿的旧入口(`replaceText`、快速编辑)仍由编辑器把文本 + 引用重建为 content,方向是「文本 → content」,不存在「legacy 字段 → content」的回退。
|
- 需要文本草稿的旧入口(`replaceText`、快速编辑)仍由编辑器把文本 + 引用重建为 content,方向是「文本 → content」,不存在「legacy 字段 → content」的回退。
|
||||||
|
|
||||||
## 证据
|
## 证据
|
||||||
|
|
||||||
- `apps/ai-game-creator-shell/tests/resourceReferences.test.ts`:content 原样传递、空白 part 保留、有效性判断、文本派生。
|
- `apps/ai-game-creator-shell/tests/resourceReferences.test.ts`:content 原样传递、空白 part 保留、有效性判断、文本派生。
|
||||||
- `apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx`(含「引用后面的段落分隔原样落进 content」)、`chatPromptPolish.test.tsx`、`tests/appSurface/*.suite.ts`:草稿读取、提醒判据、队列与 caller 迁移到 content-only,并按逐字投影断言 content。
|
- `apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx`、`chatPromptPolish.test.tsx`、`tests/appSurface/*.suite.ts`:草稿读取、提醒判据、队列与 caller 迁移到 content-only。
|
||||||
- `apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/`:`validation.rs` 的「只有整条 content 全空白才算空输入」与 `wire.rs` 的「单个纯空白 part 通过校验、整条全空白拒绝」用例。
|
|
||||||
- AGC shell 类型检查、定向 Vitest、`npm run check:encoding`、`git diff --check` 通过。
|
- AGC shell 类型检查、定向 Vitest、`npm run check:encoding`、`git diff --check` 通过。
|
||||||
|
|||||||
@@ -8775,13 +8775,3 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
|
|||||||
- 决策:DirectProject 本地上传不区分图片与其它文件。用户使用同一个文件入口,前端不按 `mediaType` 做图片判断,所有上传文件统一写成 `agc_attachment_reference`;项目资源 `@` 引用仍使用 `agc_resource_reference`。
|
- 决策:DirectProject 本地上传不区分图片与其它文件。用户使用同一个文件入口,前端不按 `mediaType` 做图片判断,所有上传文件统一写成 `agc_attachment_reference`;项目资源 `@` 引用仍使用 `agc_resource_reference`。
|
||||||
- 原因:原 `agc_image_reference` 与附件 payload 完全相同,Rust wire 投影也把两者合并成同一段文本,不能代表真实的多模态图片输入。保留该 discriminator 只会制造错误语义。
|
- 原因:原 `agc_image_reference` 与附件 payload 完全相同,Rust wire 投影也把两者合并成同一段文本,不能代表真实的多模态图片输入。保留该 discriminator 只会制造错误语义。
|
||||||
- 边界:本次不新增 `input_image`,不保留图片类型兼容分支,不迁移旧历史;图片多模态能力未来单独设计独立 payload 与 wire 投影。
|
- 边界:本次不新增 `input_image`,不保留图片类型兼容分支,不迁移旧历史;图片多模态能力未来单独设计独立 payload 与 wire 投影。
|
||||||
|
|
||||||
## 2026-09-17 DirectProject canonical content 的有效性只判整条 content
|
|
||||||
|
|
||||||
- 决策:canonical user item 的有效输入判据只落在**整条 content** 上——只要有一段非空白文本、或任何一个非文本 part 就算有效输入;单个纯空白 `input_text`(段落分隔、软换行、chip 后的分隔空格)是合法 part。Rust `validate_direct_codex_user_item` 的 `content_has_meaningful_input` 与前端 `hasMeaningfulDirectCodexContent` 同口径,`wire.rs` 的「不能转换为空 prompt」只作兜底。
|
|
||||||
- 决策:编辑器投影层(`ResourceReferenceInput` 的 `collectDraftParts`)原样透传编辑器节点:不做空白过滤,也不与相邻 part 合并。前端不替用户改写他输入的内容,canonical content 与编辑器内容逐字对应。
|
|
||||||
- 决策:content → 可读文本只有 `directCodexContentToPromptText(content, assets)` 一个口径,`assets`(当前项目 manifest)必填:消息正文、队列 chip 文案、润色判据、草稿持久化与出站 prompt 全部由它派生,`agc_resource_reference` 按 `@显示名` 展开,只有素材已不在清单里时才回落 `resourceId`。
|
|
||||||
- 原因:`3c7b02b9f` 为了让 content 通过「空 `input_text`」校验而在投影层丢空白 part,把引用后的段落分隔一起丢了(`@素材` 与下一段粘成一个词);`41366dd71` 又把消息 / 队列 / 快速编辑的文本派生切到这条投影上,缺陷扩散到界面与出站 prompt。
|
|
||||||
- 边界:不新增 content part 类型,不迁移历史(历史 content 原样回放),不为旧口径保留兼容分支;前端仍不发整条全空白的一轮,app-server 输入里出现纯空白 text item 由本决定接受。
|
|
||||||
- 验证:Rust `validation.rs` / `wire.rs` 用例「单个纯空白 part 通过校验、整条全空白拒绝」;AGC 侧 `resourceReferenceInput.test.tsx`、`resourceReferences.test.ts`、`appSurface/project-development.suite.ts`(Godot 回合)、`projectResourceLiveIntegration.test.tsx` 改为按逐字投影断言,`ai-game-creator-shell:typecheck` 与定向 vitest 通过。
|
|
||||||
- 关联规范:`docs/project-memory/plans/【里程碑】DirectProject canonical content严格边界-2026-09-16.md`。
|
|
||||||
|
|||||||
@@ -5616,20 +5616,3 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/`
|
|||||||
- fixture 子进程统一清除 `GIT_*` 环境,并用一次性外层 linked worktree 验证引用、索引、配置不变;原有工程检查规则保持完整,不能用逐项关闭规则修复 fixture 污染。
|
- fixture 子进程统一清除 `GIT_*` 环境,并用一次性外层 linked worktree 验证引用、索引、配置不变;原有工程检查规则保持完整,不能用逐项关闭规则修复 fixture 污染。
|
||||||
- Vitest 的 `toHaveBeenCalledWith` 匹配任意一次调用,失败输出会列出其它命令;应先定位相同命令的真实参数差异,不能由其它调用的序号推断时序故障。
|
- Vitest 的 `toHaveBeenCalledWith` 匹配任意一次调用,失败输出会列出其它命令;应先定位相同命令的真实参数差异,不能由其它调用的序号推断时序故障。
|
||||||
- 存在后台轮询的 IPC mock 不应要求目标命令占据全局最后一次调用。验证刷新时先记录调用边界,再筛选该边界之后的目标命令,严格核对其最后一次参数,避免后台查询影响断言,也避免旧调用掩盖刷新未执行。
|
- 存在后台轮询的 IPC mock 不应要求目标命令占据全局最后一次调用。验证刷新时先记录调用边界,再筛选该边界之后的目标命令,严格核对其最后一次参数,避免后台查询影响断言,也避免旧调用掩盖刷新未执行。
|
||||||
|
|
||||||
## 2026-09-16 Lexical 投影丢掉引用后的换行:`@素材` 和下一段粘成一个词
|
|
||||||
|
|
||||||
- **现象**:聊天输入区里先 `@` 一个素材、回车换段再写文字,提交出去的 canonical content 里没有任何分隔,直接读成 `@hero把这一版改成夜景`;同一个字符串还会进 agent 输入、队列 chip 文案与润色判据。
|
|
||||||
- **原因**:`3c7b02b9f`(2026-09-15)为了让 content 通过 Rust 的「空 `input_text`」校验,在投影层加了 `appendInputText`(`if (text.trim())` 才落 part,并与相邻文本合并)。root 子节点之间补的段落分隔符与 `LineBreakNode` 传进来的都是 `'\n'`,`trim()` 为空 ⇒ 整段丢掉;chip 后那一段文字随后另起一个 part,派生文本用 `''` 直接拼接,于是粘成 `@hero把这一版改成夜景`。`41366dd71` 又把消息正文 / 队列 chip / 快速编辑的文本派生切到这条投影上,缺陷扩散到界面与出站 prompt。
|
|
||||||
- **处理(最终口径)**:不保留任何前端过滤,而是去掉规则和它的成因——Rust `validate_direct_codex_user_item` 改成只判整条 content(`content_has_meaningful_input`:有一段非空白文本或任意非文本 part 即有效),单个纯空白 `input_text` 合法;`ResourceReferenceInput` 的投影原样透传编辑器节点,既不丢空白也不与相邻 part 合并。中间版本(把待写文本「向前合并」到下一个 part)已随之删除:它仍会丢掉尾随换行与「两个 chip 之间只隔一个换行」的分隔,也仍要让前端替用户改写内容。
|
|
||||||
- **验证**:Rust `validation.rs` / `wire.rs` 新增「单个纯空白 part 通过校验、整条全空白拒绝」用例;`apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx`「引用后面的段落分隔原样落进 content」断言 `[ref, { type: 'input_text', text: '\n' }, { type: 'input_text', text: '…' }]` 与派生文本逐字一致;`tests/appSurface/project-development.suite.ts` 的 Godot 用例断言 Shift+Enter 的两个换行各自成 part。
|
|
||||||
- **关联**:`apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx`(`collectDraftParts`)、`apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs`、`docs/project-memory/plans/【里程碑】DirectProject canonical content严格边界-2026-09-16.md`。
|
|
||||||
|
|
||||||
## 2026-09-16 派生文本漏传 `manifest.assets`:`@引用` 从显示名退化成内部 id
|
|
||||||
|
|
||||||
- **现象**:快速编辑面板里 `@` 插了素材再点「修改」,出站 `derive_local_project_resource` 的 `prompt` 是 `把夜色改成星空@source-rules`,而界面 chip 与聊天输入区显示的是 `@rules`;仓库自带的 `projectResourceLiveIntegration` 用例因此长期是红的(`expected '把夜色改成星空@rules' to be '把夜色改成星空@source-rules'`)。同一根因还让排队消息 chip 显示 `@asset:…`。
|
|
||||||
- **原因**:`directCodexContentToPromptText(content, assets)` 的 `assets` 有默认值 `[]`,漏传不报错、只是把 `agc_resource_reference` 退化成 `${resourceId}`。`ResourceReferenceInput` 自己读草稿的四处都带了 `manifest.assets`,而它的三个消费方漏传:`chatComposerQueue.queuedChatTurnLabel`(宿主 `ComposerTurnQueue` 也没接素材清单)、`project-development/index.tsx` 的 `applyResourceQuickEditPrompt` 与 `applyResourceQuickEditDraft`;后两个的 `useCallback` 依赖里同样没有 `manifest.assets`,改完还会读到旧清单。
|
|
||||||
- **处理**:三个消费方全部补上素材清单并进依赖数组——`queuedChatTurnLabel(turn, assets)` + `ComposerTurnQueue` 新增 `assets` 属性(由 `ProjectSupervisorView` 传 `chatProjectAssets`)、快速编辑的两处改用 `manifest.assets`。改「比较用的草稿文本」与「落进面板的文本」必须同一个口径,否则 `replaceText` 会每次输入都重跑一遍。
|
|
||||||
- **加固**:`directCodexContentToPromptText(content, assets)` 与 `queuedChatTurnLabel(turn, assets)` 的 `assets` 改为**必填**(删掉 `= []` 默认值),测试里刻意不传 manifest 的地方显式写 `[]`。理由:默认值把「漏传素材清单」从编译期错误降级成运行期文案退化,正是本条缺陷的入口;队列 chip 与消息正文从此共用同一个派生(`queuedChatTurnLabel` 只多做「压成单行 + 限长」)。
|
|
||||||
- **验证**:`apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx` 的「快速编辑提示词里能 @ 出资源选择器」由红转绿(该文件 29 passed);`tests/appSurface/chat-composer.suite.ts` 新增「队列 chip 的 @ 引用按 manifest 显示名展开」;`appSurface.test.ts` 467 passed、定向 51 passed、`npm run ai-game-creator-shell:typecheck`、`npm run check:encoding` 通过。
|
|
||||||
- **关联**:`apps/ai-game-creator-shell/src/features/project-workspace/chatComposerQueue.ts`、`ComposerControls.tsx`、`ProjectSupervisorView.tsx`、`apps/ai-game-creator-shell/src/view/project-development/index.tsx`、`apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx`。
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
更新时间:2026-09-08
|
更新时间:2026-09-08
|
||||||
|
|
||||||
AGC 聊天输入框支持以结构化引用标记当前项目已登记素材。输入 `@` 会按素材名称、资源 ID 和类型过滤候选项;也可以点击输入框右侧的 `@` 按钮打开素材选择面板。
|
AGC 聊天输入框支持以结构化引用标记当前项目已登记素材,并提供 Codex 风格的 Skill 提及。输入 `@` 会按素材名称、资源 ID 和类型过滤候选项;输入 `$` 会按当前 DirectProject 可用 Skill 名称过滤候选项;也可以点击输入框右侧的 `@` 按钮打开素材选择面板。
|
||||||
|
|
||||||
素材选择面板支持缩略图、名称或资源 ID 搜索、类型筛选和多选;面板顶部有两个页签:
|
素材选择面板支持缩略图、名称或资源 ID 搜索、类型筛选和多选;面板顶部有两个页签:
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@ AGC 聊天输入框支持以结构化引用标记当前项目已登记素材。
|
|||||||
|
|
||||||
两个页签各自持有独立的搜索与类型筛选状态,互不影响,也不与资源画布筛选联动。当前版本取 `ResourceReferenceInput` 的 `activeVersionId`;未传或传 `null` 时回退到 manifest `versions[]` 中最新的那个版本。版本不存在或该版本没有绑定素材时页签显示空态,不合成资源卡;绑定指向已删除资源(悬空绑定)时按资源 `id` 过滤掉。
|
两个页签各自持有独立的搜索与类型筛选状态,互不影响,也不与资源画布筛选联动。当前版本取 `ResourceReferenceInput` 的 `activeVersionId`;未传或传 `null` 时回退到 manifest `versions[]` 中最新的那个版本。版本不存在或该版本没有绑定素材时页签显示空态,不合成资源卡;绑定指向已删除资源(悬空绑定)时按资源 `id` 过滤掉。
|
||||||
|
|
||||||
确认后素材以 `@素材名` 芯片插入编辑器,用户可以在芯片前后继续编辑自然语言,也可以单独删除芯片。芯片内部保存稳定 `resourceId`,展示名称只用于界面,不参与引用解析;资源改名后,编辑区已有芯片与候选列表都会按 `resourceId` 刷新成 manifest 的最新显示名,并同步回父级草稿。
|
确认后素材以 `@素材名` 芯片插入编辑器,Skill 以 `$skill-name` 芯片插入编辑器;用户可以在芯片前后继续编辑自然语言,也可以单独删除芯片。素材芯片内部保存稳定 `resourceId`,展示名称只用于界面,不参与引用解析。Skill 芯片保存稳定 Skill 名称,发送时由 Rust 根据当前 DirectProject 已启用 Skill 清单解析为 Codex 原生 `type: "skill"` 输入项。资源改名后,编辑区已有芯片与候选列表都会按 `resourceId` 刷新成 manifest 的最新显示名,并同步回父级草稿。
|
||||||
|
|
||||||
提交时前端把 Lexical 草稿直接编码为受限 Response API user `message` item:`input_text` 与 AGC 引用 part 按编辑顺序内联在同一个 `content[]` 中。资源引用只携带稳定 `resourceId`;运行画面引用携带区域语义摘要及关联资源 ID。Rust 是唯一 schema source(通过 `ts-rs` 生成 TypeScript 绑定),在发起回合前完成 item 白名单、字段边界、manifest 归属和路径安全校验;校验失败时本轮不持久化、不发送。通过校验的 canonical item 以 `response_item` envelope 写入项目历史,随后由 Rust 将 AGC part 临时转换为 Codex 可接受的 `input_text`,保持原始 content 顺序。已有标准 `response_item` 原样读取与复用;旧 legacy conversation 行不再提供 fallback。
|
提交时前端把 Lexical 草稿直接编码为受限 Response API user `message` item:`input_text` 与 AGC 引用 part 按编辑顺序内联在同一个 `content[]` 中。资源引用只携带稳定 `resourceId`;运行画面引用携带区域语义摘要及关联资源 ID。Rust 是唯一 schema source(通过 `ts-rs` 生成 TypeScript 绑定),在发起回合前完成 item 白名单、字段边界、manifest 归属和路径安全校验;校验失败时本轮不持久化、不发送。通过校验的 canonical item 以 `response_item` envelope 写入项目历史,随后由 Rust 将 AGC part 临时转换为 Codex 可接受的 `input_text`,保持原始 content 顺序。已有标准 `response_item` 原样读取与复用;旧 legacy conversation 行不再提供 fallback。
|
||||||
|
|
||||||
@@ -21,6 +21,7 @@ AGC 聊天输入框支持以结构化引用标记当前项目已登记素材。
|
|||||||
|
|
||||||
- 三个聊天入口共用 `ResourceReferenceInput`;
|
- 三个聊天入口共用 `ResourceReferenceInput`;
|
||||||
- 输入 `@` 触发候选,支持键盘选择和 Esc 关闭;
|
- 输入 `@` 触发候选,支持键盘选择和 Esc 关闭;
|
||||||
|
- 输入 `$` 触发 Skill 候选,支持键盘选择和 Esc 关闭;Skill 候选只显示当前 DirectProject 已启用且已由 app-server 发现的 Skill;
|
||||||
- `@` 按钮打开素材选择面板;
|
- `@` 按钮打开素材选择面板;
|
||||||
- 支持搜索、类型筛选和多选;
|
- 支持搜索、类型筛选和多选;
|
||||||
- 素材芯片可插入、编辑和删除;
|
- 素材芯片可插入、编辑和删除;
|
||||||
@@ -32,3 +33,4 @@ AGC 聊天输入框支持以结构化引用标记当前项目已登记素材。
|
|||||||
- 素材选择面板的「当前版本素材 / 全部画布素材」两个页签与独立筛选、搜索状态;
|
- 素材选择面板的「当前版本素材 / 全部画布素材」两个页签与独立筛选、搜索状态;
|
||||||
- 资源改名后引用芯片与候选列表的显示名自动刷新;
|
- 资源改名后引用芯片与候选列表的显示名自动刷新;
|
||||||
- 切换 / 重开会话恢复草稿后光标落在文本末尾,引用按原 content 顺序恢复为 inline 芯片。
|
- 切换 / 重开会话恢复草稿后光标落在文本末尾,引用按原 content 顺序恢复为 inline 芯片。
|
||||||
|
- Skill 提及按原 content 顺序恢复为 inline 芯片;未知、禁用或未发现 Skill 在发送前失败关闭,不写入历史、不启动回合。
|
||||||
|
|||||||
Reference in New Issue
Block a user