WIP: feat/支持引用skill #364

Closed
k88936 wants to merge 114 commits from feat/more-ref into master
47 changed files with 2205 additions and 676 deletions
@@ -1133,7 +1133,6 @@ fn direct_codex_thread_delta_event(
) -> DirectThreadEvent {
DirectThreadEvent::item_delta(item_id, kind, direct_thread_delta_text(root, delta))
}
/// 通知 → 回合事件的唯一分类函数:运行态读取器与单测共用这一份。
///
/// 读取器只负责"必须有 turnId 才处理"的前置条件与节流(活动 / 正文),分类不在这里之外
@@ -2961,8 +2960,22 @@ impl CodexAppServerConnection {
codex_app_server_text_prompt(&request)
.map_err(platform_llm::LlmError::InvalidRequest)?
};
let input =
codex_app_server_turn_input(&request, &prompt, &self.inner.workspace_path).await?;
let input = if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
if let Some(item) = direct_user_item {
let canonical: DirectCodexUserItem = serde_json::from_value(item.clone())
.map_err(|error| platform_llm::LlmError::InvalidRequest(error.to_string()))?;
direct_codex_user_item_to_codex_turn_input(
&self.inner.workspace_path,
&canonical,
self.inner._skill_roots.as_deref().unwrap_or_default(),
)
.map_err(platform_llm::LlmError::InvalidRequest)?
} else {
codex_app_server_turn_input(&request, &prompt, &self.inner.workspace_path).await?
}
} else {
codex_app_server_turn_input(&request, &prompt, &self.inner.workspace_path).await?
};
let _direct_tool_bridge_turn_guard =
if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
Some(
@@ -2,9 +2,9 @@
//! 有项目路径或导入状态时输出路径映射;否则保持首页元数据文案。不灌正文。
pub(crate) const MAX_DIRECT_CODEX_ATTACHMENTS: usize = 8;
const MAX_DIRECT_CODEX_ATTACHMENT_NAME_CHARS: usize = 160;
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_NAME_CHARS: usize = 160;
pub(crate) const MAX_DIRECT_CODEX_ATTACHMENT_MEDIA_TYPE_CHARS: usize = 96;
pub(crate) const MAX_DIRECT_CODEX_ATTACHMENT_LOCAL_PATH_CHARS: usize = 512;
const HOME_ATTACHMENT_HEADER: &str =
"[首页附件说明:当前尚未打开项目,以下仅为附件元数据,附件内容尚不可读取]";
@@ -5,11 +5,11 @@ mod validation;
mod wire;
pub(crate) use model::{
DirectCodexUserContentPart, DirectCodexUserItem, DirectCodexUserMessageItem,
DirectCodexUserRole, DirectCodexUserRuntimeRegionPart,
DirectCodexUserAttachmentReferencePart, DirectCodexUserContentPart, DirectCodexUserItem,
DirectCodexUserMessageItem, DirectCodexUserRole, DirectCodexUserRuntimeRegionPart,
};
pub(crate) use validation::validate_direct_codex_user_item;
pub(crate) use wire::{
direct_codex_user_item_to_prompt, direct_codex_user_item_to_response_item,
direct_codex_user_item_to_wire_input,
direct_codex_user_item_to_codex_turn_input, direct_codex_user_item_to_prompt,
direct_codex_user_item_to_response_item, direct_codex_user_item_to_wire_input,
};
@@ -34,8 +34,25 @@ pub(crate) enum DirectCodexUserContentPart {
InputText { text: String },
#[serde(rename = "agc_resource_reference")]
AgcResourceReference { resource_id: String },
#[serde(rename = "agc_skill_reference")]
AgcSkillReference { name: String },
#[serde(rename = "agc_runtime_region_reference")]
AgcRuntimeRegionReference(DirectCodexUserRuntimeRegionPart),
/// Uploaded project attachment kept inline in canonical content.
#[serde(rename = "agc_attachment_reference")]
AgcAttachmentReference(DirectCodexUserAttachmentReferencePart),
}
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))]
pub(crate) struct DirectCodexUserAttachmentReferencePart {
pub(crate) name: String,
pub(crate) media_type: String,
#[ts(type = "number")]
pub(crate) size: u64,
pub(crate) local_path: String,
pub(crate) status: String,
}
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
@@ -4,6 +4,8 @@ use super::model::{
};
use crate::agent::{
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;
@@ -12,7 +14,7 @@ pub(crate) const MAX_DIRECT_CODEX_REFERENCES: usize = 32;
pub(crate) fn validate_direct_codex_user_item(
root: &Path,
item: &DirectCodexUserItem,
) -> Result<(), String> {
) -> Result<GameCreationAppManifest, String> {
let DirectCodexUserItem::Message(message) = item;
if !matches!(message.role, DirectCodexUserRole::User) {
return Err("DirectProject 只接受 user message item".to_string());
@@ -20,32 +22,91 @@ pub(crate) fn validate_direct_codex_user_item(
if message.id.trim().is_empty() {
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());
}
let manifest = read_manifest_for_project(root)?;
let mut reference_count = 0usize;
let mut attachment_count = 0usize;
for part in &message.content {
match part {
DirectCodexUserContentPart::InputText { text } => {
if text.trim().is_empty() {
return Err("DirectProject input_text 不能为空".to_string());
}
}
DirectCodexUserContentPart::InputText { .. } => {}
DirectCodexUserContentPart::AgcResourceReference { resource_id } => {
reference_count = reference_count.saturating_add(1);
validate_resource_id_and_manifest(&manifest, resource_id)?;
}
DirectCodexUserContentPart::AgcSkillReference { name } => {
let name = name.trim();
if name.is_empty()
|| name.chars().count() > 120
|| matches!(name, "." | "..")
|| name.chars().any(|character| {
character.is_control()
|| character.is_whitespace()
|| matches!(character, '/' | '\\' | ':' | '$')
})
{
return Err("引用的 Skill 名称无效,请移除后重新选择".to_string());
}
}
DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => {
reference_count = reference_count.saturating_add(1);
validate_runtime_region_reference(&manifest, reference)?;
}
DirectCodexUserContentPart::AgcAttachmentReference(reference) => {
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 {
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(
@@ -86,3 +147,150 @@ fn validate_runtime_region_reference(
}
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,11 @@
use super::model::{DirectCodexUserContentPart, DirectCodexUserItem};
use super::model::{
DirectCodexUserContentPart, DirectCodexUserItem, DirectCodexUserRuntimeRegionPart,
};
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,GameCreationAppManifest,
};
use serde_json::Value;
use std::path::Path;
@@ -51,6 +56,47 @@ fn direct_codex_user_item_to_response_content(
.collect()
}
fn resource_reference_summary(
manifest: &GameCreationAppManifest,
resource_id: &str,
) -> Result<String, String> {
let resource_id = resource_id.trim();
let asset = manifest
.assets
.iter()
.find(|asset| asset.id == resource_id)
.ok_or_else(|| "引用的素材已不存在,请移除后重新选择".to_string())?;
let path = sanitize_attachment_local_path(&asset.local_path)
.ok_or_else(|| "引用的素材路径无效,请移除后重新选择".to_string())?;
Ok(format!(
"[素材引用 resourceId={resource_id};项目路径={path}]"
))
}
fn runtime_region_summary(reference: &DirectCodexUserRuntimeRegionPart) -> String {
let resources = reference
.resource_ids
.iter()
.map(|id| id.trim())
.collect::<Vec<_>>()
.join(",");
let mut summary = format!("[运行画面区域:名称={} ", reference.label.trim());
if let Some(run_id) = reference.run_id.as_deref() {
summary.push_str(&format!("运行标识={} ", run_id.trim()));
}
if let Some(role) = reference.element_role.as_deref() {
summary.push_str(&format!("角色={} ", role.trim()));
}
if let Some(text) = reference.text.as_deref() {
summary.push_str(&format!("文本={} ", text.trim()));
}
if !resources.is_empty() {
summary.push_str(&format!("关联素材={resources}"));
}
summary.push(']');
summary
}
/// 将 canonical user item 转为 app-server `turn/start.input` 可接受的文本数组。
/// AGC 私有 part 只在这里投影为安全摘要,canonical item 本身不被修改。
pub(crate) fn direct_codex_user_item_to_wire_input(
@@ -65,38 +111,26 @@ pub(crate) fn direct_codex_user_item_to_wire_input(
let text = match part {
DirectCodexUserContentPart::InputText { text } => text.clone(),
DirectCodexUserContentPart::AgcResourceReference { resource_id } => {
let asset = manifest
.assets
.iter()
.find(|asset| asset.id == resource_id.trim())
.ok_or_else(|| "引用的素材已不存在,请移除后重新选择".to_string())?;
let path = sanitize_attachment_local_path(&asset.local_path)
.ok_or_else(|| "引用的素材路径无效,请移除后重新选择".to_string())?;
format!(
"[素材引用 resourceId={};项目路径={path}]",
resource_id.trim()
)
resource_reference_summary(&manifest, resource_id)?
}
DirectCodexUserContentPart::AgcSkillReference { name } => {
format!("${}", name.trim())
}
DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => {
let resources = reference
.resource_ids
.iter()
.map(|id| id.trim())
.collect::<Vec<_>>()
.join(",");
let mut summary = format!("[运行画面区域:名称={} ", reference.label.trim());
if let Some(run_id) = reference.run_id.as_deref() {
summary.push_str(&format!("运行标识={} ", run_id.trim()));
}
if let Some(role) = reference.element_role.as_deref() {
summary.push_str(&format!("角色={} ", role.trim()));
}
if let Some(text) = reference.text.as_deref() {
summary.push_str(&format!("文本={} ", text.trim()));
}
if !resources.is_empty() {
summary.push_str(&format!("关联素材={resources}"));
runtime_region_summary(reference)
}
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
}
@@ -106,6 +140,66 @@ pub(crate) fn direct_codex_user_item_to_wire_input(
Ok(Value::Array(input))
}
pub(crate) fn direct_codex_user_item_to_codex_turn_input(
root: &Path,
item: &DirectCodexUserItem,
skill_roots: &[std::path::PathBuf],
) -> Result<Value, String> {
let manifest = validate_direct_codex_user_item(root, item)?;
let DirectCodexUserItem::Message(message) = item;
let mut input = Vec::with_capacity(message.content.len());
for part in &message.content {
match part {
DirectCodexUserContentPart::InputText { text } => {
input.push(serde_json::json!({ "type": "text", "text": text }));
}
DirectCodexUserContentPart::AgcResourceReference { resource_id } => {
input.push(serde_json::json!({
"type": "text",
"text": resource_reference_summary(&manifest, resource_id)?,
}));
}
DirectCodexUserContentPart::AgcSkillReference { name } => {
let name = name.trim();
let path = skill_roots
.iter()
.map(|root| root.join(name).join("SKILL.md"))
.find(|path| path.is_file())
.ok_or_else(|| "引用的 Skill 当前不可用,请重新选择".to_string())?;
input.push(serde_json::json!({
"type": "skill",
"name": name,
"path": path,
}));
}
DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => {
input.push(serde_json::json!({
"type": "text",
"text": runtime_region_summary(reference),
}));
}
DirectCodexUserContentPart::AgcAttachmentReference(reference) => {
let mut summary = format!(
"[附件:名称={};类型={};大小={} 字节",
reference.name.trim(),
reference.media_type.trim(),
reference.size
);
if !reference.local_path.trim().is_empty() {
summary.push_str(&format!(";项目路径={}", reference.local_path.trim()));
}
summary.push_str(&format!(";状态={}", reference.status.trim()));
summary.push(']');
input.push(serde_json::json!({
"type": "text",
"text": summary,
}));
}
}
}
Ok(Value::Array(input))
}
pub(crate) fn direct_codex_user_item_to_prompt(
root: &Path,
item: &DirectCodexUserItem,
@@ -130,7 +224,8 @@ pub(crate) fn direct_codex_user_item_to_prompt(
#[cfg(test)]
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 std::path::Path;
@@ -174,4 +269,99 @@ mod tests {
.expect_err("history item without type must fail");
assert!(error.contains("缺少 type"), "{error}");
}
#[test]
fn attachment_parts_remain_in_canonical_order_when_projected() {
let root = tempfile::tempdir().expect("temp project");
crate::init_local_game_project_at(root.path(), "wire-test", "wire 投影测试")
.expect("init project");
let item = json!({
"type": "message",
"role": "user",
"id": "turn-1:user",
"content": [
{"type": "input_text", "text": "先看"},
{"type": "agc_attachment_reference", "name": "notes.txt", "mediaType": "text/plain", "size": 4, "localPath": "assets/notes.txt", "status": "imported"}
]
});
let projected = direct_codex_user_item_to_response_item(root.path(), &item)
.expect("user response item should project");
let content = projected["content"].as_array().expect("content array");
assert_eq!(content.len(), 2);
assert!(content[0]["text"].as_str().unwrap().contains("先看"));
assert!(content[1]["text"].as_str().unwrap().contains("notes.txt"));
}
#[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(
project_path: String,
prompt: String,
mut user_item: DirectCodexUserItem,
user_item: DirectCodexUserItem,
creation_type: Option<String>,
client_turn_id: Option<String>,
attachments: Option<Vec<DirectCodexTurnAttachment>>,
) -> Result<String, String> {
let root = Path::new(project_path.trim());
let turn_id = normalize_direct_client_turn_id(client_turn_id.as_deref())?;
@@ -43,34 +42,9 @@ pub(crate) async fn chat_with_game_creator_direct_codex(
redact_agent_runtime_error(root, &format!("恢复上一轮陶泥儿整包事务失败:{error}"), 500)
})?;
let turn_emitter = DirectGameCreatorTurnUpdateEmitter::new(root, turn_id.clone());
let mut audit = DirectCodexTurnAudit::start(
root,
&turn_id,
&prompt,
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
})?;
validate_direct_codex_user_item(root, &user_item)?;
let user_prompt = direct_codex_user_item_to_prompt(root, &user_item)?;
if user_prompt.trim().is_empty() {
audit.finish(false);
return Err("聊天内容不能为空".to_string());
}
let canonical_user_item =
@@ -80,18 +54,15 @@ pub(crate) async fn chat_with_game_creator_direct_codex(
&user_prompt,
creation_type.as_deref(),
Some(&turn_emitter),
Some(&mut audit),
// DirectProject 的完整回合权威已经落在 project.jsonl;不再创建平行审计日志。
None,
canonical_user_item,
)
.await
{
Ok(reply) => reply,
Err(error) => {
audit.finish(false);
return Err(error);
}
Err(error) => return Err(error),
};
audit.finish(true);
turn_emitter.emit("completed", Some("none"), Some(reply.clone()), None);
Ok(reply)
}
@@ -1,4 +1,4 @@
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::borrow::Cow;
use std::collections::BTreeSet;
@@ -121,6 +121,13 @@ struct AgcSkillManifestEntry {
sha256: String,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct AgcSkillCatalogEntry {
pub(crate) name: String,
pub(crate) description: String,
}
fn is_safe_skill_relative_path(value: &str) -> bool {
let path = Path::new(value);
!value.is_empty()
@@ -234,6 +241,21 @@ pub(crate) fn agc_skill_pack_fingerprint() -> Result<String, String> {
Ok(format!("{:x}", Sha256::digest(canonical_manifest.as_ref())))
}
/// 返回当前客户端随 AGC 一起启用的内置 Skill 候选。
///
/// 前端不得复制审核清单;Skill 名称和描述统一从经过校验的资源 manifest 派生。
#[tauri::command]
pub(crate) fn list_agc_skill_catalog() -> Result<Vec<AgcSkillCatalogEntry>, String> {
Ok(validated_skill_pack_manifest()?
.skills
.into_iter()
.map(|entry| AgcSkillCatalogEntry {
name: entry.name,
description: entry.purpose,
})
.collect())
}
pub(crate) fn render_agc_skill_pack_index() -> Result<String, String> {
let manifest = validated_skill_pack_manifest()?;
let mut lines = vec![format!(
@@ -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]
fn skill_content_digest_is_stable_across_lf_and_crlf() {
fn digest(bytes: &[u8]) -> String {
@@ -2518,6 +2518,7 @@ fn main() {
pick_client_extension_file,
pick_client_extension_directory,
list_client_extensions,
list_agc_skill_catalog,
import_client_extension,
set_client_extension_enabled,
rename_client_extension,
+109 -60
View File
@@ -291,7 +291,11 @@ import type {
} from './features/project-workspace/resourceReferences';
import {
chatComposerDraftToDirectCodexUserItem,
directCodexContentToPromptText,
directCodexUserItemFromContent,
hasMeaningfulDirectCodexContent,
RESOURCE_REFERENCE_INSERT_EVENT,
resourceReferenceFromAsset,
type ResourceReferenceInsertEventDetail,
} from './features/project-workspace/resourceReferences';
import { SupervisorChatOnlyView } from './features/project-workspace/SupervisorChatOnlyView';
@@ -4072,9 +4076,39 @@ export function App({
}
function handleChatComposerChange(draft: ChatComposerDraft) {
setChatInput(draft.text);
setChatReferences(draft.references);
setChatContent(draft.content ?? []);
const content = draft.content;
setChatContent(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(() => {
@@ -6474,12 +6508,13 @@ export function App({
if (directProjectPath && directProjectId && directInvoke) {
const clientTurnId =
directConversationTurnId ?? createDirectCodexConversationTurnId();
const effectiveUserItem =
userItem ??
chatComposerDraftToDirectCodexUserItem(
{ text: prompt, references: references ?? [], content: [] },
directCodexConversationMessageId(clientTurnId, 'user'),
if (!userItem) {
setProjectSupervisorRuntimeError(
'DirectProject 回合缺少 canonical user item,未发送本轮消息。',
);
return;
}
const effectiveUserItem = userItem;
if (
!directPolicyChecked &&
projectConversationWriteConfirmedRef.current !== directProjectPath
@@ -6606,7 +6641,6 @@ export function App({
prompt: string;
clientTurnId: string;
creationType?: HomeCreationType;
attachments?: DirectCodexTurnAttachment[];
userItem: ReturnType<typeof chatComposerDraftToDirectCodexUserItem>;
} = {
projectPath: directProjectPath,
@@ -6617,9 +6651,6 @@ export function App({
if (creationType) {
directTurnInput.creationType = creationType;
}
if (attachments?.length) {
directTurnInput.attachments = attachments;
}
directTurnInput.userItem = effectiveUserItem;
// 回复正文按条目身份从线程事件 / 历史切片进聊天,本地不再补一条,
// 因此这里只等回合跑完,不接返回值。
@@ -6704,6 +6735,7 @@ export function App({
setDirectCodexTurnCancelling(false);
chatAgentBusyRef.current = false;
// 本地 invoke 正常收尾仍可直接推进队列;恢复场景则由 turn.completed effect 推进。
// 只有当前回合的收尾才能释放发送队列,不能覆盖后来启动的回合。
dispatchNextQueuedChatTurn();
}
}
@@ -6925,11 +6957,21 @@ export function App({
updatedAt: Date.now(),
},
]);
const initialUserItem = directConversationTurnId
? directCodexUserItemFromContent(
[
{ type: 'input_text', text: latch.prompt },
...attachmentContentParts(latch.attachments),
],
directCodexConversationMessageId(directConversationTurnId, 'user'),
)
: undefined;
void executeChatAgentReplyRef.current({
prompt: latch.prompt,
clientTurnId: directConversationTurnId,
creationType: latch.creationType,
attachments: latch.attachments,
...(initialUserItem ? { userItem: initialUserItem } : {}),
});
}, [
chatAgentBusy,
@@ -11990,36 +12032,28 @@ export function App({
* / id/
*/
function startDirectCodexConversationTurn(input: {
prompt: string;
attachments?: DirectCodexTurnAttachment[];
references?: ChatReference[];
content?: DirectCodexUserContentPart[];
clientTurnId: string;
userItem: ReturnType<typeof chatComposerDraftToDirectCodexUserItem>;
}) {
const clientTurnId = createDirectCodexConversationTurnId();
const prompt = directCodexContentToPromptText(
input.userItem.content,
manifest.assets,
);
supervisorChatShouldFollowLatestRef.current = true;
setMessages((current) => [
...current,
{
role: 'user',
text: input.prompt,
text: prompt,
runtimeOwned: true,
messageId: directCodexConversationMessageId(clientTurnId, 'user'),
messageId: directCodexConversationMessageId(input.clientTurnId, 'user'),
updatedAt: Date.now(),
},
]);
void executeChatAgentReply({
prompt: input.prompt,
clientTurnId,
attachments: input.attachments?.length ? input.attachments : undefined,
references: input.references,
userItem: chatComposerDraftToDirectCodexUserItem(
{
text: input.prompt,
references: input.references ?? [],
content: input.content ?? [],
},
directCodexConversationMessageId(clientTurnId, 'user'),
),
prompt,
clientTurnId: input.clientTurnId,
userItem: input.userItem,
});
}
@@ -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 队列;队列满时拒绝并保留草稿,不静默丢消息。 */
function enqueueChatTurnForRunningTurn(input: {
prompt: string;
attachments: DirectCodexTurnAttachment[];
references: ChatReference[];
content: DirectCodexUserContentPart[];
clientTurnId: string;
userItem: ReturnType<typeof chatComposerDraftToDirectCodexUserItem>;
}): boolean {
if (isChatTurnQueueFull(chatTurnQueueRef.current)) {
setChatComposerNotice(chatQueueFullNotice());
@@ -12094,10 +12140,8 @@ export function App({
queuedChatTurnSequenceRef.current += 1;
const turn = createQueuedChatTurn({
id: `queued-chat-turn-${Date.now()}-${queuedChatTurnSequenceRef.current}`,
prompt: input.prompt,
attachments: input.attachments,
references: input.references,
content: input.content,
clientTurnId: input.clientTurnId,
userItem: input.userItem,
createdAt: Date.now(),
});
const nextQueue = enqueueChatTurn(chatTurnQueueRef.current, turn);
@@ -12132,10 +12176,8 @@ export function App({
setChatComposerNotice('');
}
startDirectCodexConversationTurn({
prompt: next.prompt,
attachments: next.attachments,
references: next.references,
content: next.content,
clientTurnId: next.clientTurnId,
userItem: next.userItem,
});
}
@@ -12216,6 +12258,14 @@ export function App({
const prompt = chatInput.trim();
const references = chatReferences;
const pendingAttachments = chatAttachments;
const content = [
...chatContent,
...attachmentContentParts(pendingAttachments),
];
const canonicalPrompt = directCodexContentToPromptText(
content,
manifest.assets,
);
if (
!directCodexProductRuntime &&
supervisorChatOnly &&
@@ -12231,23 +12281,20 @@ export function App({
setProjectSupervisorRuntimeError('请先回答项目总控 Agent 当前的澄清问题');
return;
}
if (
!prompt &&
references.length === 0 &&
chatContent.length === 0 &&
pendingAttachments.length === 0
) {
if (!hasMeaningfulDirectCodexContent(content)) {
return;
}
if (supervisorChatBusy) {
// 回合运行中再次发送:direct-codex 面板把消息放进本地 FIFO 队列,当前回合结束后
// 依次发出;其它面板保持原有"运行中不接受新输入"的行为。
if (directCodexProductRuntime) {
const clientTurnId = createDirectCodexConversationTurnId();
const enqueued = enqueueChatTurnForRunningTurn({
prompt,
attachments: pendingAttachments,
references,
content: chatContent,
clientTurnId,
userItem: directCodexUserItemFromContent(
content,
directCodexConversationMessageId(clientTurnId, 'user'),
),
});
if (enqueued) {
setChatInput('');
@@ -12271,7 +12318,7 @@ export function App({
return;
}
if (planningV2ActiveRef.current || planningStartMode) {
if (!prompt || chatAgentBusy) {
if (!canonicalPrompt || chatAgentBusy) {
return;
}
supervisorChatShouldFollowLatestRef.current = true;
@@ -12281,13 +12328,13 @@ export function App({
...current,
{
role: 'user',
text: prompt,
text: canonicalPrompt,
runtimeOwned: true,
messageId: `planning-v2:${clientTurnId}:user`,
updatedAt: Date.now(),
},
]);
void executeChatAgentReply({ prompt, clientTurnId });
void executeChatAgentReply({ prompt: canonicalPrompt, clientTurnId });
return;
}
if (supervisorChatOnly || directCodexProductRuntime) {
@@ -12301,11 +12348,13 @@ export function App({
setChatContent([]);
setChatAttachmentNotice('');
setChatComposerNotice('');
const clientTurnId = createDirectCodexConversationTurnId();
startDirectCodexConversationTurn({
prompt,
attachments: pendingAttachments,
references,
content: chatContent,
clientTurnId,
userItem: directCodexUserItemFromContent(
content,
directCodexConversationMessageId(clientTurnId, 'user'),
),
});
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>
);
}
@@ -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 { useEffect, useRef, useState } from 'react';
import type { GameCreationAppAssetManifestEntry } from '../../../../../packages/shared/src/contracts/gameCreationApp';
import { resolveTauriInvoke } from '../../app/tauri';
import type {
GameCreatorAppConfigView,
@@ -192,9 +193,12 @@ export function ComposerPendingAttachments({
/** 队列 chip:回合运行中入队的消息,按 FIFO 顺序展示,可单条取消。 */
export function ComposerTurnQueue({
turns,
assets,
onCancel,
}: {
turns: readonly QueuedChatTurn[];
/** 与聊天消息渲染同源的素材清单:chip 文案里的 `@` 引用按它展开成显示名。 */
assets: readonly GameCreationAppAssetManifestEntry[];
onCancel: (id: string) => void;
}) {
if (turns.length === 0) {
@@ -211,11 +215,11 @@ export function ComposerTurnQueue({
{index + 1}
</span>
<span className="project-supervisor-composer-queue-text">
{queuedChatTurnLabel(turn)}
{queuedChatTurnLabel(turn, assets)}
</span>
<button
type="button"
aria-label={`取消排队消息 ${queuedChatTurnLabel(turn)}`}
aria-label={`取消排队消息 ${queuedChatTurnLabel(turn, assets)}`}
title="取消这条排队消息"
onClick={() => onCancel(turn.id)}
>
@@ -692,6 +692,7 @@ export function ProjectSupervisorView({
>
<ComposerTurnQueue
turns={directCodex ? queuedTurns : []}
assets={chatProjectAssets}
onCancel={(id) => onCancelQueuedTurn?.(id)}
/>
<ComposerPendingAttachments
@@ -4,6 +4,16 @@ import { X } from 'lucide-react';
import type { ChatReference } from './resourceReferences';
function chipTitle(reference: ChatReference) {
if (reference.type === 'resource') {
return `${reference.label} · ${reference.kind}`;
}
if (reference.type === 'skill') {
return `${reference.name} · Skill`;
}
return `${reference.label} · 运行区域`;
}
export function ResourceReferenceChip({
reference,
nodeKey,
@@ -21,18 +31,19 @@ export function ResourceReferenceChip({
data-runtime-region-reference={
reference.type === 'runtime-region' ? 'true' : undefined
}
contentEditable={false}
title={
reference.type === 'resource'
? `${reference.label} · ${reference.kind}`
: `${reference.label} · 运行区域`
data-skill-reference-name={
reference.type === 'skill' ? reference.name : undefined
}
contentEditable={false}
title={chipTitle(reference)}
>
<span aria-hidden="true">@</span>
<span className="resource-reference-chip-label">{reference.label}</span>
<span aria-hidden="true">{reference.type === 'skill' ? '$' : '@'}</span>
<span className="resource-reference-chip-label">
{reference.type === 'skill' ? reference.name : reference.label}
</span>
<button
type="button"
aria-label={`移除引用 ${reference.label}`}
aria-label={`移除引用 ${reference.type === 'skill' ? reference.name : reference.label}`}
onMouseDown={(event) => event.preventDefault()}
onClick={() => {
editor.update(() => {
File diff suppressed because it is too large Load Diff
@@ -4,36 +4,33 @@
* 回合运行中用户再次发送时,消息进入 FIFO 队列而不是被丢弃;当前回合结束后按入队顺序
* 依次发出。队列项能在输入盒上方单独取消。这里只放与 React 无关的纯逻辑,便于单测。
*/
import type { DirectCodexTurnAttachment } from '../app-shell/directCodexTurnAttachments';
import type { DirectCodexUserContentPart } from './generated';
import type { ChatReference } from './resourceReferences';
import type { GameCreationAppAssetManifestEntry } from '../../../../../packages/shared/src/contracts/gameCreationApp';
import type { DirectCodexUserItem } from './generated';
import { directCodexContentToPromptText } from './resourceReferences';
/** 队列上限:满了以后拒绝入队并给出可读提示,而不是静默丢消息。 */
export const MAX_QUEUED_CHAT_TURNS = 5;
export type QueuedChatTurn = {
id: string;
prompt: string;
attachments: DirectCodexTurnAttachment[];
references: ChatReference[];
content?: DirectCodexUserContentPart[];
clientTurnId: string;
userItem: DirectCodexUserItem;
createdAt: number;
};
export function createQueuedChatTurn(input: {
id: string;
prompt: string;
attachments?: readonly DirectCodexTurnAttachment[];
references?: readonly ChatReference[];
content?: readonly DirectCodexUserContentPart[];
clientTurnId: string;
userItem: DirectCodexUserItem;
createdAt: number;
}): QueuedChatTurn {
return {
id: input.id,
prompt: input.prompt,
attachments: [...(input.attachments ?? [])],
references: [...(input.references ?? [])],
content: [...(input.content ?? [])],
clientTurnId: input.clientTurnId,
userItem: {
...input.userItem,
content: [...input.userItem.content],
},
createdAt: input.createdAt,
};
}
@@ -77,17 +74,23 @@ export function chatQueueFullNotice(): string {
return `队列已满(最多 ${MAX_QUEUED_CHAT_TURNS} 条),请等当前回合结束后再发送`;
}
/** 队列 chip 上显示的文字:单行、有长度上限。 */
export function queuedChatTurnLabel(turn: QueuedChatTurn): string {
const text = turn.prompt.trim().replace(/\s+/gu, ' ');
if (text) {
return text.length > 24 ? `${text.slice(0, 24)}` : text;
}
if (turn.attachments.length > 0) {
return `附件 · ${turn.attachments[0]?.name ?? '未命名'}`;
}
if (turn.references.length > 0) {
return '素材引用';
}
return '未命名消息';
/**
* 队列 chip 上显示的文字:与真实消息同一个派生(`directCodexContentToPromptText`),
* 再压成单行并限长。
*
* `assets` 与聊天消息渲染同源(`manifest.assets`)且必填:`@` 引用按显示名展开,chip
* 与消息正文逐字一致,不会露出 `@asset:…` 这种内部 id。
*/
export function queuedChatTurnLabel(
turn: QueuedChatTurn,
assets: readonly GameCreationAppAssetManifestEntry[],
): string {
const text = directCodexContentToPromptText(turn.userItem.content, assets)
.trim()
.replace(/\s+/gu, ' ');
return text
? text.length > 24
? `${text.slice(0, 24)}`
: text
: '未命名消息';
}
@@ -1,8 +1,5 @@
import { resolveTauriInvoke } from '../../app/tauri';
import {
type ChatComposerDraft,
chatReferenceListKey,
} from './resourceReferences';
import type { DirectCodexUserContentPart } from './generated';
/**
* 「不再提醒」偏好存本机 localStorage,不进 manifest、不进后端。
@@ -61,8 +58,10 @@ export function writeChatPromptPolishReminderDisabled(disabled: boolean) {
}
/** 草稿指纹:用于判断「本轮草稿」是否已经被润色或确认过。 */
export function chatPromptDraftKey(draft: ChatComposerDraft) {
return `${draft.text}\u0000${chatReferenceListKey(draft.references)}`;
export function chatPromptDraftKey(
content: readonly DirectCodexUserContentPart[],
) {
return JSON.stringify(content);
}
/**
@@ -73,25 +72,27 @@ export function chatPromptDraftKey(draft: ChatComposerDraft) {
* 4. 草稿不是以 `/` 开头的命令 —— 命令走直通路径,不参与提醒。
*/
export function shouldRemindChatPromptPolish({
draft,
content,
prompt,
acknowledgedDraftKey,
reminderDisabled,
}: {
draft: ChatComposerDraft;
content: readonly DirectCodexUserContentPart[];
prompt: string;
acknowledgedDraftKey: string | null;
reminderDisabled: boolean;
}) {
if (reminderDisabled) {
return false;
}
const text = draft.text.trim();
const text = prompt.trim();
if (text.length < CHAT_PROMPT_POLISH_REMINDER_MIN_TEXT_LENGTH) {
return false;
}
if (text.startsWith('/')) {
return false;
}
return chatPromptDraftKey(draft) !== acknowledgedDraftKey;
return chatPromptDraftKey(content) !== acknowledgedDraftKey;
}
/**
@@ -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;
};
@@ -1,10 +1,14 @@
// 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';
export type DirectCodexUserContentPart =
| { type: 'input_text'; text: string }
| { type: 'agc_resource_reference'; resourceId: string }
| { type: 'agc_skill_reference'; name: string }
| ({
type: 'agc_runtime_region_reference';
} & DirectCodexUserRuntimeRegionPart);
} & DirectCodexUserRuntimeRegionPart)
| ({
type: 'agc_attachment_reference';
} & DirectCodexUserAttachmentReferencePart);
@@ -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';
export type DirectCodexUserItem = DirectCodexUserMessageItem;
/**
* DirectProject 本轮 user input 的唯一结构化入口。
*/
export type DirectCodexUserItem = {
type: 'message';
} & DirectCodexUserMessageItem;
@@ -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 };
@@ -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 { DirectCodexUserRole } from './DirectCodexUserRole';
export type DirectCodexUserMessageItem = {
type: 'message';
role: DirectCodexUserRole;
content: DirectCodexUserContentPart[];
content: Array<DirectCodexUserContentPart>;
id: string;
};
@@ -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';

Some files were not shown because too many files have changed in this diff Show More