Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 38ad2ce256 | |||
| 65f8e44a88 | |||
| ade1b7803e | |||
| 71000b6df1 | |||
| c047543825 | |||
| 34b3af1d2a | |||
| f7cba30b6b | |||
| 273f12633c | |||
| 5f5aa17152 | |||
| 3a57d9fbdf | |||
| 382b925ab9 | |||
| c1482012c6 | |||
| c3a17a6efc | |||
| 1bfdc3a760 | |||
| 1e27cd229d | |||
| 774710452f | |||
| aaecb82622 | |||
| 64ad24ac77 | |||
| 75ec3361dc | |||
| bc1dc868a1 | |||
| 50204ff7aa | |||
| 70fa160819 |
@@ -174,6 +174,14 @@ _Avoid_: mock 先行堆积、前后端各自发散、先做排行榜 UI
|
||||
|
||||
## 项目开发对话(DirectProject)
|
||||
|
||||
**DirectProject 专属聊天模块**:
|
||||
AGC 普通项目聊天的独立容器,拥有 DirectProject 的聊天状态、运行态订阅、历史读取、发送队列、附件和中止交互,并把聊天投影交给专属表现层渲染;它不承接 Supervisor、Design Agent 或 Planning V2 的运行态。
|
||||
_Avoid_: 把 DirectProject 作为项目总控聊天的一个布尔分支、把四种 Agent 会话抽象成同一事实源
|
||||
|
||||
**项目工作台布局**:
|
||||
承载本地项目的资源工作区、项目级工具和独立聊天产品路径的外层界面;布局拥有跨面板的账户/钱包入口,聊天模块只负责项目对话,不嵌套账户展示。
|
||||
_Avoid_: 把钱包入口塞进聊天设置、让聊天组件拥有工作台级账户状态
|
||||
|
||||
**项目对话历史**:
|
||||
AGC 本地项目内 Codex 原始对话条目的持久集合,是聊天展示、工具卡片和线程恢复注入的唯一持久事实源。
|
||||
_Avoid_: 会话缓存、展示态历史、按 UI 需要另存的对话副本
|
||||
|
||||
@@ -2960,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 =
|
||||
"[首页附件说明:当前尚未打开项目,以下仅为附件元数据,附件内容尚不可读取]";
|
||||
|
||||
@@ -10,6 +10,6 @@ pub(crate) use model::{
|
||||
};
|
||||
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,6 +34,8 @@ pub(crate) enum DirectCodexUserContentPart {
|
||||
InputText { text: String },
|
||||
#[serde(rename = "agc_resource_reference")]
|
||||
AgcResourceReference { resource_id: String },
|
||||
#[serde(rename = "agc_skill_reference")]
|
||||
AgcSkillReference { name: String },
|
||||
#[serde(rename = "agc_runtime_region_reference")]
|
||||
AgcRuntimeRegionReference(DirectCodexUserRuntimeRegionPart),
|
||||
/// Uploaded project attachment kept inline in canonical content.
|
||||
|
||||
+149
-4
@@ -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());
|
||||
@@ -27,6 +29,7 @@ pub(crate) fn validate_direct_codex_user_item(
|
||||
}
|
||||
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 { .. } => {}
|
||||
@@ -34,19 +37,59 @@ pub(crate) fn validate_direct_codex_user_item(
|
||||
reference_count = reference_count.saturating_add(1);
|
||||
validate_resource_id_and_manifest(&manifest, resource_id)?;
|
||||
}
|
||||
DirectCodexUserContentPart::AgcSkillReference { name } => {
|
||||
let name = name.trim();
|
||||
if name.is_empty()
|
||||
|| name.chars().count() > 120
|
||||
|| matches!(name, "." | "..")
|
||||
|| name.chars().any(|character| {
|
||||
character.is_control()
|
||||
|| character.is_whitespace()
|
||||
|| matches!(character, '/' | '\\' | ':' | '$')
|
||||
})
|
||||
{
|
||||
return Err("引用的 Skill 名称无效,请移除后重新选择".to_string());
|
||||
}
|
||||
}
|
||||
DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => {
|
||||
reference_count = reference_count.saturating_add(1);
|
||||
validate_runtime_region_reference(&manifest, reference)?;
|
||||
}
|
||||
DirectCodexUserContentPart::AgcAttachmentReference(reference) => {
|
||||
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!(reference.status.trim(), "imported" | "failed") {
|
||||
if !matches!(status, "imported" | "failed") {
|
||||
return Err("附件状态无效".to_string());
|
||||
}
|
||||
}
|
||||
@@ -55,7 +98,7 @@ pub(crate) fn validate_direct_codex_user_item(
|
||||
if reference_count > MAX_DIRECT_CODEX_REFERENCES {
|
||||
return Err(format!("一次最多引用 {MAX_DIRECT_CODEX_REFERENCES} 个素材"));
|
||||
}
|
||||
Ok(())
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
/// 整条 content 是否还有有效输入:任何一段非空白文本、或任何一个非文本 part 都算。
|
||||
@@ -107,8 +150,9 @@ fn validate_runtime_region_reference(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::content_has_meaningful_input;
|
||||
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 {
|
||||
@@ -148,4 +192,105 @@ mod tests {
|
||||
},
|
||||
]));
|
||||
}
|
||||
|
||||
#[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,41 +111,73 @@ 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
|
||||
}
|
||||
};
|
||||
input.push(serde_json::json!({ "type": "text", "text": text }));
|
||||
}
|
||||
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!(
|
||||
"[附件:名称={};类型={};大小={} 字节",
|
||||
@@ -112,10 +190,12 @@ pub(crate) fn direct_codex_user_item_to_wire_input(
|
||||
}
|
||||
summary.push_str(&format!(";状态={}", reference.status.trim()));
|
||||
summary.push(']');
|
||||
summary
|
||||
input.push(serde_json::json!({
|
||||
"type": "text",
|
||||
"text": summary,
|
||||
}));
|
||||
}
|
||||
};
|
||||
input.push(serde_json::json!({ "type": "text", "text": text }));
|
||||
}
|
||||
}
|
||||
Ok(Value::Array(input))
|
||||
}
|
||||
@@ -212,6 +292,35 @@ mod tests {
|
||||
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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -2,7 +2,7 @@ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext
|
||||
import { $getNodeByKey, type NodeKey } from 'lexical';
|
||||
import { Paperclip, X } from 'lucide-react';
|
||||
|
||||
import type { DirectCodexUserAttachmentReferencePart } from './generated';
|
||||
import type { DirectCodexUserAttachmentReferencePart } from '../../view/project-development/chat/generated/DirectCodexUserAttachmentReferencePart';
|
||||
|
||||
export function AttachmentReferenceChip({
|
||||
attachment,
|
||||
|
||||
+1
-1
@@ -9,8 +9,8 @@ import {
|
||||
} from 'lexical';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import type { DirectCodexUserAttachmentReferencePart } from '../../view/project-development/chat/generated/DirectCodexUserAttachmentReferencePart';
|
||||
import { AttachmentReferenceChip } from './AttachmentReferenceChip';
|
||||
import type { DirectCodexUserAttachmentReferencePart } from './generated';
|
||||
|
||||
export type SerializedAttachmentReferenceNode = Spread<
|
||||
{
|
||||
|
||||
+1
-17
@@ -1,5 +1,4 @@
|
||||
import { Settings2, ShieldCheck, Wallet, X } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Settings2, ShieldCheck, X } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import {
|
||||
@@ -17,19 +16,16 @@ import { RuntimeConfigDialog } from '../runtime-config/RuntimeConfigDialog';
|
||||
* 组成:
|
||||
* - 「运行配置」打开既有 `RuntimeConfigDialog`(独立浮层,自己有 backdrop);
|
||||
* - 「操作权限」打开由 `ApprovalModeDialog` 提供的选择面板;
|
||||
* - 「泥点」直接渲染父级传入的 `walletEntry`(为空则整行不渲染)。
|
||||
*/
|
||||
export function ProjectSupervisorSettingsDialog({
|
||||
projectPath,
|
||||
currentApprovalLabel,
|
||||
onOpenApproval,
|
||||
walletEntry,
|
||||
onClose,
|
||||
}: {
|
||||
projectPath: string;
|
||||
currentApprovalLabel: string;
|
||||
onOpenApproval: () => void;
|
||||
walletEntry?: ReactNode;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [runtimeConfigOpen, setRuntimeConfigOpen] = useState(false);
|
||||
@@ -90,18 +86,6 @@ export function ProjectSupervisorSettingsDialog({
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
{walletEntry ? (
|
||||
<div className="project-supervisor-settings-row is-static">
|
||||
<span className="project-supervisor-settings-row-main">
|
||||
<Wallet size={16} aria-hidden="true" />
|
||||
<span>
|
||||
<strong>泥点</strong>
|
||||
<small>当前余额与充值入口</small>
|
||||
</span>
|
||||
</span>
|
||||
<div className="game-workbench-chat-wallet">{walletEntry}</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
+90
-474
File diff suppressed because it is too large
Load Diff
+19
-8
@@ -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(() => {
|
||||
|
||||
+170
-32
@@ -61,6 +61,7 @@ import {
|
||||
createProjectResourcePreviewRequestId,
|
||||
createProjectResourcePreviewScopeId,
|
||||
} from '../../services/projectResourcePreviewTransport';
|
||||
import type { DirectCodexUserContentPart } from '../../view/project-development/chat/generated/DirectCodexUserContentPart';
|
||||
import {
|
||||
$createAttachmentReferenceNode,
|
||||
$isAttachmentReferenceNode,
|
||||
@@ -73,7 +74,6 @@ import {
|
||||
writeChatPromptPolishReminderDisabled,
|
||||
} from './chatPromptPolish';
|
||||
import { ChatPromptPolishReminder } from './ChatPromptPolishReminder';
|
||||
import type { DirectCodexUserContentPart } from './generated';
|
||||
import {
|
||||
$createResourceReferenceNode,
|
||||
$isResourceReferenceNode,
|
||||
@@ -98,6 +98,7 @@ import {
|
||||
resourceReferenceMatchesTagSelection,
|
||||
type ResourceReferenceScope,
|
||||
resourceReferenceTagLibrary,
|
||||
type SkillReference,
|
||||
} from './resourceReferences';
|
||||
import { usePromptPolish } from './usePromptPolish';
|
||||
|
||||
@@ -108,6 +109,7 @@ type ResourceReferenceInputProps = {
|
||||
onEditorStateChange?: (editorState: EditorState) => void;
|
||||
initialContent?: DirectCodexUserContentPart[];
|
||||
assets: GameCreationAppAssetManifestEntry[];
|
||||
skills?: SkillReference[];
|
||||
projectPath: string;
|
||||
/**
|
||||
* `@` 面板「当前版本素材」页签使用的版本 id。
|
||||
@@ -181,12 +183,22 @@ class ResourceMentionOption extends MenuOption {
|
||||
}
|
||||
}
|
||||
|
||||
class SkillMentionOption extends MenuOption {
|
||||
skill: SkillReference;
|
||||
|
||||
constructor(skill: SkillReference) {
|
||||
super(skill.name);
|
||||
this.skill = skill;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑器节点 → canonical content part,**原样透传**:段落分隔(root 子节点之间补的
|
||||
* `\n`)、软换行、chip 后的分隔空格都各自成 part,不做空白过滤、不与相邻 part 合并。
|
||||
* 有效输入只在整条 content 上判定(`hasMeaningfulDirectCodexContent` 与 Rust
|
||||
* `validate_direct_codex_user_item` 同口径),前端不替用户改写他输入了什么。
|
||||
*/
|
||||
|
||||
function collectDraftParts(
|
||||
node: LexicalNode,
|
||||
references: ChatReference[],
|
||||
@@ -244,8 +256,12 @@ function readDraftFromNodes(): ChatComposerDraft {
|
||||
const projection = readDraftProjectionFromNodes();
|
||||
const labels = new Map(
|
||||
projection.references.map((reference) => [
|
||||
reference.type === 'resource' ? reference.resourceId : reference.label,
|
||||
`@${reference.label}`,
|
||||
reference.type === 'resource'
|
||||
? reference.resourceId
|
||||
: reference.type === 'skill'
|
||||
? reference.name
|
||||
: reference.label,
|
||||
reference.type === 'skill' ? `$${reference.name}` : `@${reference.label}`,
|
||||
]),
|
||||
);
|
||||
const text = projection.content
|
||||
@@ -256,7 +272,7 @@ function readDraftFromNodes(): ChatComposerDraft {
|
||||
? (labels.get(part.resourceId) ?? `@${part.resourceId}`)
|
||||
: part.type === 'agc_runtime_region_reference'
|
||||
? `@${part.label}`
|
||||
: `@${part.name}`,
|
||||
: `$${part.name}`,
|
||||
)
|
||||
.join('')
|
||||
.trim();
|
||||
@@ -308,7 +324,7 @@ function findDraftMentionToken(line: string, token: string, from: number) {
|
||||
* 把「文本 + 引用列表」这对外部表示切成编辑区要写的内容。
|
||||
*
|
||||
* 不变量:写入编辑器后,编辑器读回的草稿就是唯一当前值。`collectDraftParts`
|
||||
* 会把每个 chip 读成一段 `@显示名` 文本,所以 chip 只能内联嵌在文本里同名 token 的位置上,
|
||||
* 会把每个 chip 读成一段 `@显示名` / `$skill-name` 文本,所以 chip 只能内联嵌在文本里同名 token 的位置上,
|
||||
* 不能另起一段堆在末尾;编辑器本身是唯一 authority,不再通过 props 比较后反复重建。
|
||||
*
|
||||
* 文本里已经没有对应 token 的引用(例如 AI 润色整体改写了文本)内联补到最后一段末尾,
|
||||
@@ -320,7 +336,8 @@ function buildDraftSegments(
|
||||
): DraftBuildSegment[][] {
|
||||
const pending = references.map((reference) => ({
|
||||
reference,
|
||||
token: `@${reference.label}`,
|
||||
token:
|
||||
reference.type === 'skill' ? `$${reference.name}` : `@${reference.label}`,
|
||||
used: false,
|
||||
}));
|
||||
const lines: DraftBuildSegment[][] = [];
|
||||
@@ -393,6 +410,9 @@ function referenceFromContentPart(
|
||||
const asset = assetsById.get(part.resourceId);
|
||||
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') {
|
||||
return {
|
||||
type: 'runtime-region',
|
||||
@@ -540,6 +560,7 @@ function ResourceReferenceEditor({
|
||||
onEditorStateChange,
|
||||
initialContent,
|
||||
assets,
|
||||
skills = [],
|
||||
projectPath,
|
||||
activeVersionId = null,
|
||||
versions,
|
||||
@@ -556,14 +577,18 @@ function ResourceReferenceEditor({
|
||||
const [editor] = useLexicalComposerContext();
|
||||
const skipInitialDraftChangeRef = useRef(false);
|
||||
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 skillCatalogRequestedRef = useRef(false);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
// Enter 提交要避开候选浮层。用 ref 让命令处理器读到最新状态,不必因为
|
||||
// query / pickerOpen 变化反复重注册 HIGH 优先级命令。
|
||||
const mentionMenuOpenRef = useRef(false);
|
||||
const pickerVisibleRef = useRef(false);
|
||||
useEffect(() => {
|
||||
mentionMenuOpenRef.current = query !== null;
|
||||
}, [query]);
|
||||
mentionMenuOpenRef.current = query !== null || skillQuery !== null;
|
||||
}, [query, skillQuery]);
|
||||
useEffect(() => {
|
||||
pickerVisibleRef.current = pickerOpen;
|
||||
}, [pickerOpen]);
|
||||
@@ -577,6 +602,53 @@ function ResourceReferenceEditor({
|
||||
bottom: number;
|
||||
width: number;
|
||||
} | null>(null);
|
||||
// Skill 清单来自宿主:只在用户真正打开 `$` 候选时查一次。输入区挂载即发起
|
||||
// Tauri 调用会让「工作区路径非法时不产生任何后端访问」的边界失效。
|
||||
useEffect(() => {
|
||||
if (skillQuery === null || skillCatalogRequestedRef.current) return;
|
||||
const invoke = resolveTauriInvoke();
|
||||
if (!invoke) return;
|
||||
skillCatalogRequestedRef.current = true;
|
||||
void invoke<Array<{ name: string; description: string }>>(
|
||||
'list_agc_skill_catalog',
|
||||
)
|
||||
.then((items) => {
|
||||
setBuiltinSkills(
|
||||
items.map((item) => ({
|
||||
type: 'skill' as const,
|
||||
name: item.name,
|
||||
description: item.description,
|
||||
})),
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
setBuiltinSkills([]);
|
||||
});
|
||||
void invoke<
|
||||
Array<{
|
||||
name: string;
|
||||
extensionType: string;
|
||||
enabled: boolean;
|
||||
status: string;
|
||||
}>
|
||||
>('list_client_extensions')
|
||||
.then((items) => {
|
||||
setClientSkills(
|
||||
items
|
||||
.filter(
|
||||
(item) =>
|
||||
item.extensionType === 'skill' &&
|
||||
item.enabled &&
|
||||
item.status === 'enabled',
|
||||
)
|
||||
.map((item) => ({ type: 'skill' as const, name: item.name })),
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
setClientSkills([]);
|
||||
});
|
||||
}, [skillQuery]);
|
||||
|
||||
const assetsContentSignature = assetsSignature(assets);
|
||||
const versionsContentSignature = iterationsSignature(versions);
|
||||
const assetsById = useMemo(
|
||||
@@ -641,11 +713,35 @@ function ResourceReferenceEditor({
|
||||
.map((reference) => new ResourceMentionOption(reference));
|
||||
}, [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('@', {
|
||||
minLength: 0,
|
||||
maxLength: 64,
|
||||
allowWhitespace: false,
|
||||
});
|
||||
const skillTriggerFn = useBasicTypeaheadTriggerMatch('$', {
|
||||
minLength: 0,
|
||||
maxLength: 64,
|
||||
allowWhitespace: false,
|
||||
});
|
||||
|
||||
const insertReferences = useCallback(
|
||||
(nextReferences: ChatReference[]) => {
|
||||
@@ -771,13 +867,13 @@ function ResourceReferenceEditor({
|
||||
const currentRefKey = current.references
|
||||
?.map(
|
||||
(reference) =>
|
||||
`${reference.type}:${reference.type === 'resource' ? reference.resourceId : reference.label}`,
|
||||
`${reference.type}:${reference.type === 'resource' ? reference.resourceId : reference.type === 'skill' ? reference.name : reference.label}`,
|
||||
)
|
||||
.join('|');
|
||||
const desiredRefKey = desiredRefs
|
||||
.map(
|
||||
(reference) =>
|
||||
`${reference.type}:${reference.type === 'resource' ? reference.resourceId : reference.label}`,
|
||||
`${reference.type}:${reference.type === 'resource' ? reference.resourceId : reference.type === 'skill' ? reference.name : reference.label}`,
|
||||
)
|
||||
.join('|');
|
||||
if (current.text === desiredValue && currentRefKey === desiredRefKey)
|
||||
@@ -854,30 +950,47 @@ function ResourceReferenceEditor({
|
||||
[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(
|
||||
(
|
||||
option: ResourceMentionOption,
|
||||
textNodeContainingQuery: TextNode | null,
|
||||
closeMenu: () => void,
|
||||
) => {
|
||||
textNodeContainingQuery?.remove();
|
||||
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(option.reference, textNodeContainingQuery, closeMenu);
|
||||
},
|
||||
[],
|
||||
[insertReferenceNode],
|
||||
);
|
||||
|
||||
const handleSelectSkill = useCallback(
|
||||
(
|
||||
option: SkillMentionOption,
|
||||
textNodeContainingQuery: TextNode | null,
|
||||
closeMenu: () => void,
|
||||
) => {
|
||||
insertReferenceNode(option.skill, textNodeContainingQuery, closeMenu);
|
||||
},
|
||||
[insertReferenceNode],
|
||||
);
|
||||
|
||||
// —— C8 AI 润色与发送前提醒 ——
|
||||
@@ -1017,7 +1130,9 @@ function ResourceReferenceEditor({
|
||||
acknowledgedDraftKeyRef.current = null;
|
||||
}, [resetPromptPolish]);
|
||||
|
||||
const renderMentionMenu: MenuRenderFn<ResourceMentionOption> = useCallback(
|
||||
const renderMentionMenu: MenuRenderFn<
|
||||
ResourceMentionOption | SkillMentionOption
|
||||
> = useCallback(
|
||||
(_anchorElementRef, itemProps) => {
|
||||
const inputRect = rootRef.current?.getBoundingClientRect();
|
||||
if (!inputRect || itemProps.options.length === 0) {
|
||||
@@ -1052,7 +1167,7 @@ function ResourceReferenceEditor({
|
||||
<div
|
||||
className="resource-reference-menu"
|
||||
role="listbox"
|
||||
aria-label="候选素材"
|
||||
aria-label="候选引用"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: `${top}px`,
|
||||
@@ -1075,8 +1190,16 @@ function ResourceReferenceEditor({
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => itemProps.selectOptionAndCleanUp(option)}
|
||||
>
|
||||
<span>{option.reference.label}</span>
|
||||
<small>{option.reference.kind}</small>
|
||||
<span>
|
||||
{'reference' in option
|
||||
? `@${option.reference.label}`
|
||||
: `$${option.skill.name}`}
|
||||
</span>
|
||||
<small>
|
||||
{'reference' in option
|
||||
? option.reference.kind
|
||||
: (option.skill.description ?? 'Skill')}
|
||||
</small>
|
||||
</button>
|
||||
))}
|
||||
</div>,
|
||||
@@ -1201,7 +1324,22 @@ function ResourceReferenceEditor({
|
||||
onSelectOption={(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"
|
||||
preselectFirstItem
|
||||
/>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* 依次发出。队列项能在输入盒上方单独取消。这里只放与 React 无关的纯逻辑,便于单测。
|
||||
*/
|
||||
import type { GameCreationAppAssetManifestEntry } from '../../../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import type { DirectCodexUserItem } from './generated';
|
||||
import type { DirectCodexUserItem } from '../../view/project-development/chat/generated/DirectCodexUserItem';
|
||||
import { directCodexContentToPromptText } from './resourceReferences';
|
||||
|
||||
/** 队列上限:满了以后拒绝入队并给出可读提示,而不是静默丢消息。 */
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { resolveTauriInvoke } from '../../app/tauri';
|
||||
import type { DirectCodexUserContentPart } from './generated';
|
||||
import type { DirectCodexUserContentPart } from '../../view/project-development/chat/generated/DirectCodexUserContentPart';
|
||||
|
||||
/**
|
||||
* 「不再提醒」偏好存本机 localStorage,不进 manifest、不进后端。
|
||||
|
||||
@@ -5,13 +5,11 @@
|
||||
* 入口转发:前端不再自己抄一份形状,字段增删必须改 Rust。
|
||||
*/
|
||||
|
||||
export type {
|
||||
DirectThreadConsumeResult,
|
||||
DirectThreadDeltaKind,
|
||||
DirectThreadEvent,
|
||||
DirectThreadFileChange,
|
||||
DirectThreadHistorySlice,
|
||||
DirectThreadItem,
|
||||
DirectThreadRequestKind,
|
||||
DirectThreadSubscriptionBootstrap,
|
||||
} from './generated';
|
||||
export type { DirectThreadConsumeResult } from '../../view/project-development/chat/generated/DirectThreadConsumeResult';
|
||||
export type { DirectThreadDeltaKind } from '../../view/project-development/chat/generated/DirectThreadDeltaKind';
|
||||
export type { DirectThreadEvent } from '../../view/project-development/chat/generated/DirectThreadEvent';
|
||||
export type { DirectThreadFileChange } from '../../view/project-development/chat/generated/DirectThreadFileChange';
|
||||
export type { DirectThreadHistorySlice } from '../../view/project-development/chat/generated/DirectThreadHistorySlice';
|
||||
export type { DirectThreadItem } from '../../view/project-development/chat/generated/DirectThreadItem';
|
||||
export type { DirectThreadRequestKind } from '../../view/project-development/chat/generated/DirectThreadRequestKind';
|
||||
export type { DirectThreadSubscriptionBootstrap } from '../../view/project-development/chat/generated/DirectThreadSubscriptionBootstrap';
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
export type { DirectCodexUserContentPart } from './DirectCodexUserContentPart';
|
||||
export type { DirectCodexUserAttachmentReferencePart } from './DirectCodexUserAttachmentReferencePart';
|
||||
export type { DirectCodexUserItem } from './DirectCodexUserItem';
|
||||
export type { DirectCodexUserMessageItem } from './DirectCodexUserMessageItem';
|
||||
export type { DirectCodexUserRole } from './DirectCodexUserRole';
|
||||
export type { DirectCodexUserRuntimeRegionPart } from './DirectCodexUserRuntimeRegionPart';
|
||||
export type { DirectCodexUserMessageEnvelope } from './DirectCodexUserMessageEnvelope';
|
||||
export type { DirectThreadConsumeResult } from './DirectThreadConsumeResult';
|
||||
export type { DirectThreadDeltaKind } from './DirectThreadDeltaKind';
|
||||
export type { DirectThreadEvent } from './DirectThreadEvent';
|
||||
export type { DirectThreadFileChange } from './DirectThreadFileChange';
|
||||
export type { DirectThreadHistorySlice } from './DirectThreadHistorySlice';
|
||||
export type { DirectThreadItem } from './DirectThreadItem';
|
||||
export type { DirectThreadRequestKind } from './DirectThreadRequestKind';
|
||||
export type { DirectThreadSubscriptionBootstrap } from './DirectThreadSubscriptionBootstrap';
|
||||
@@ -10,10 +10,8 @@ import {
|
||||
buildGameCreationAppAssetTagLibrary,
|
||||
type GameCreationAppAssetTagLibraryEntry,
|
||||
} from '../../../../../packages/shared/src/contracts/gameCreationAppAssetTagLibrary';
|
||||
import type {
|
||||
DirectCodexUserContentPart,
|
||||
DirectCodexUserItem,
|
||||
} from './generated';
|
||||
import type { DirectCodexUserContentPart } from '../../view/project-development/chat/generated/DirectCodexUserContentPart';
|
||||
import type { DirectCodexUserItem } from '../../view/project-development/chat/generated/DirectCodexUserItem';
|
||||
|
||||
export type ResourceReferenceSource =
|
||||
| 'asset-picker'
|
||||
@@ -46,7 +44,16 @@ export type RuntimeRegionReference = {
|
||||
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 = {
|
||||
text: string;
|
||||
@@ -107,6 +114,9 @@ export function directCodexContentToPromptText(
|
||||
if (part.type === 'agc_resource_reference') {
|
||||
return `@${labels.get(part.resourceId) ?? part.resourceId}`;
|
||||
}
|
||||
if (part.type === 'agc_skill_reference') {
|
||||
return `$${part.name}`;
|
||||
}
|
||||
if (part.type === 'agc_runtime_region_reference') {
|
||||
return `@${part.label}`;
|
||||
}
|
||||
@@ -134,7 +144,9 @@ export function chatReferenceToContentPart(
|
||||
if (reference.type === 'resource') {
|
||||
return { type: 'agc_resource_reference', resourceId: reference.resourceId };
|
||||
}
|
||||
// 生成绑定里可选字段是 `T | null`(Rust `Option<T>` 会显式序列化成 null,不是省略键)。
|
||||
if (reference.type === 'skill') {
|
||||
return { type: 'agc_skill_reference', name: reference.name };
|
||||
}
|
||||
return {
|
||||
type: 'agc_runtime_region_reference',
|
||||
label: reference.label,
|
||||
@@ -384,6 +396,9 @@ function chatReferenceKey(reference: ChatReference) {
|
||||
if (reference.type === 'resource') {
|
||||
return `resource:${reference.resourceId}:${reference.source}`;
|
||||
}
|
||||
if (reference.type === 'skill') {
|
||||
return `skill:${reference.name}`;
|
||||
}
|
||||
return `runtime-region:${runtimeRegionReferenceDiscriminators(reference)}`;
|
||||
}
|
||||
|
||||
@@ -393,11 +408,15 @@ function chatReferenceKey(reference: ChatReference) {
|
||||
*/
|
||||
export function chatReferenceListKey(references: ChatReference[]) {
|
||||
return references
|
||||
.map((reference) =>
|
||||
reference.type === 'resource'
|
||||
? `resource:${reference.resourceId}:${reference.source}:${reference.label}`
|
||||
: `runtime-region:${runtimeRegionReferenceDiscriminators(reference)}`,
|
||||
)
|
||||
.map((reference) => {
|
||||
if (reference.type === 'resource') {
|
||||
return `resource:${reference.resourceId}:${reference.source}:${reference.label}`;
|
||||
}
|
||||
if (reference.type === 'skill') {
|
||||
return `skill:${reference.name}`;
|
||||
}
|
||||
return `runtime-region:${runtimeRegionReferenceDiscriminators(reference)}`;
|
||||
})
|
||||
.join('\u0001');
|
||||
}
|
||||
|
||||
|
||||
@@ -5850,6 +5850,17 @@ iframe.preview-frame {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.game-workbench-layout-account {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 12px;
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
.game-workbench-layout-account .launcher-account-bar {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.game-workbench-layout.is-ui-editor {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
+453
@@ -0,0 +1,453 @@
|
||||
import { ArrowUp, AtSign, Loader2, Settings } from 'lucide-react';
|
||||
import type { FormEventHandler, RefObject, UIEventHandler } from 'react';
|
||||
import { Fragment, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
AgentMessageContent,
|
||||
type AgentMessageTone,
|
||||
} from '../../../../../../packages/shared/src/components/AgentMessageContent';
|
||||
import type { ChatMessage } from '../../../app/types';
|
||||
import { ChatMarkdownMessage } from '../../../components/ChatMarkdownMessage';
|
||||
import { projectWorkspaceStatusForDisplay } from '../../../features/agent-runtime';
|
||||
import type { DirectCodexTurnAttachment } from '../../../features/app-shell/directCodexTurnAttachments';
|
||||
import type { QueuedChatTurn } from '../../../features/project-workspace/chatComposerQueue';
|
||||
import {
|
||||
ComposerAttachmentMenu,
|
||||
ComposerPendingAttachments,
|
||||
ComposerReasoningEffortSelect,
|
||||
ComposerStopButton,
|
||||
ComposerTurnQueue,
|
||||
ComposerVoiceButton,
|
||||
} from '../../../features/project-workspace/ComposerControls';
|
||||
import {
|
||||
ConversationModelSelect,
|
||||
type ConversationModelSelectHandle,
|
||||
} from '../../../features/project-workspace/ConversationModelSelect';
|
||||
import type { DirectChatEntry } from '../../../features/project-workspace/directThreadChat';
|
||||
import {
|
||||
buildDirectChatTurns,
|
||||
type DirectChatBlock,
|
||||
type DirectChatTurn,
|
||||
directMessageTimestamp,
|
||||
} from '../../../features/project-workspace/directTurnPresentation';
|
||||
import { ProjectSupervisorSettingsDialog } from '../../../features/project-workspace/ProjectSupervisorSettingsDialog';
|
||||
import {
|
||||
ResourceReferenceInput,
|
||||
type ResourceReferenceInputHandle,
|
||||
} from '../../../features/project-workspace/ResourceReferenceInput';
|
||||
import type {
|
||||
ChatComposerDraft,
|
||||
ChatReference,
|
||||
} from '../../../features/project-workspace/resourceReferences';
|
||||
import { ToolCallGroup } from '../../../features/project-workspace/ToolCallGroup';
|
||||
import {
|
||||
formatClockTime,
|
||||
formatTurnDuration,
|
||||
} from '../../../features/project-workspace/toolCallGroupPresentation';
|
||||
import { type ApprovalMode, approvalModeLabel } from '../approvalMode';
|
||||
import { ApprovalModeDialog } from '../ApprovalModeDialog';
|
||||
|
||||
type AssetManifestEntry =
|
||||
import('../../../../../../packages/shared/src/contracts/gameCreationApp').GameCreationAppAssetManifestEntry;
|
||||
type GameIterationVersion =
|
||||
import('../../../../../../packages/shared/src/contracts/gameCreationApp').GameIterationVersion;
|
||||
|
||||
export type DirectProjectChatViewProps = {
|
||||
activeVersionId?: string | null;
|
||||
attachments: DirectCodexTurnAttachment[];
|
||||
attachmentNotice: string;
|
||||
chatInput: string;
|
||||
chatReferences: ChatReference[];
|
||||
chatProjectAssets: AssetManifestEntry[];
|
||||
composerRef: RefObject<ResourceReferenceInputHandle | null>;
|
||||
composerNotice: string;
|
||||
conversationMessages: ChatMessage[];
|
||||
directEntries: DirectChatEntry[];
|
||||
directTurnRunning: boolean;
|
||||
hiddenConversationCount: number;
|
||||
hasEarlierConversationMessages: boolean;
|
||||
messagesRef: RefObject<HTMLDivElement | null>;
|
||||
onCancelQueuedTurn: (id: string) => void;
|
||||
onCancelTurn: () => void;
|
||||
onChatInputChange: (draft: ChatComposerDraft) => void;
|
||||
onRemoveAttachment: (index: number) => void;
|
||||
onScroll: UIEventHandler<HTMLDivElement>;
|
||||
onShowEarlierMessages: () => void;
|
||||
onSubmit: FormEventHandler<HTMLFormElement>;
|
||||
onUploadFiles: (files: readonly File[]) => void;
|
||||
projectPath: string;
|
||||
queuedTurns: QueuedChatTurn[];
|
||||
turnCancelling: boolean;
|
||||
versions?: GameIterationVersion[];
|
||||
workspaceStatus: string;
|
||||
controlBusy: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* DirectProject 的独立表现容器。
|
||||
*
|
||||
* 这个组件只处理 DirectProject 的输入框、回合分区、运行中状态和设置表现;
|
||||
* Supervisor / Design Agent / Planning V2 的运行态不在这里出现。
|
||||
*/
|
||||
export function DirectProjectChatView({
|
||||
activeVersionId = null,
|
||||
attachments,
|
||||
attachmentNotice,
|
||||
chatInput,
|
||||
chatReferences,
|
||||
chatProjectAssets,
|
||||
composerRef,
|
||||
composerNotice,
|
||||
conversationMessages,
|
||||
directEntries,
|
||||
directTurnRunning,
|
||||
hiddenConversationCount,
|
||||
hasEarlierConversationMessages,
|
||||
messagesRef,
|
||||
onCancelQueuedTurn,
|
||||
onCancelTurn,
|
||||
onChatInputChange,
|
||||
onRemoveAttachment,
|
||||
onScroll,
|
||||
onShowEarlierMessages,
|
||||
onSubmit,
|
||||
onUploadFiles,
|
||||
projectPath,
|
||||
queuedTurns,
|
||||
turnCancelling,
|
||||
versions,
|
||||
workspaceStatus,
|
||||
controlBusy,
|
||||
}: DirectProjectChatViewProps) {
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [turnUsageNow, setTurnUsageNow] = useState(() => Date.now());
|
||||
const [voiceNotice, setVoiceNotice] = useState('');
|
||||
const [approvalOpen, setApprovalOpen] = useState(false);
|
||||
const [approvalMode, setApprovalMode] = useState<ApprovalMode>('strict');
|
||||
const [approvalNotice, setApprovalNotice] = useState('');
|
||||
const [modelReady, setModelReady] = useState(false);
|
||||
const [modelValidating, setModelValidating] = useState(false);
|
||||
const modelSelectRef = useRef<ConversationModelSelectHandle>(null);
|
||||
const modelValidateInFlightRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!directTurnRunning) return;
|
||||
setTurnUsageNow(Date.now());
|
||||
const timer = setInterval(() => setTurnUsageNow(Date.now()), 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [directTurnRunning]);
|
||||
|
||||
const directTurns = buildDirectChatTurns({
|
||||
entries: directEntries,
|
||||
localMessages: conversationMessages,
|
||||
turnRunning: directTurnRunning,
|
||||
});
|
||||
const activeTurnStartedAt =
|
||||
directTurns.find((turn) => turn.active)?.startedAt ?? 0;
|
||||
|
||||
const renderTurnUsage = (turn: DirectChatTurn) => {
|
||||
if (turn.active || !turn.startedAt) return null;
|
||||
const endedAt = Math.max(turn.endedAt, turn.startedAt);
|
||||
return (
|
||||
<p
|
||||
className="message-turn-usage"
|
||||
data-testid="turn-usage"
|
||||
data-turn-running="false"
|
||||
>
|
||||
{`本轮结束于 ${new Date(endedAt).toLocaleTimeString('zh-CN', {
|
||||
hour12: false,
|
||||
})} · 耗时 ${formatTurnDuration(endedAt - turn.startedAt) ?? '0秒'}`}
|
||||
</p>
|
||||
);
|
||||
};
|
||||
|
||||
const submitLabel = controlBusy ? '思考中' : '发送';
|
||||
const submitButton = (
|
||||
<button
|
||||
type="submit"
|
||||
className="project-supervisor-submit-button"
|
||||
aria-label={submitLabel}
|
||||
title={submitLabel}
|
||||
disabled={controlBusy || !modelReady || modelValidating}
|
||||
>
|
||||
{controlBusy ? (
|
||||
<Loader2 size={16} aria-hidden="true" className="animate-spin" />
|
||||
) : (
|
||||
<ArrowUp size={16} aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
|
||||
const renderBlock = (
|
||||
turn: DirectChatTurn,
|
||||
block: DirectChatBlock,
|
||||
tone: AgentMessageTone = 'body',
|
||||
streamingKey: string | null,
|
||||
) => {
|
||||
if (block.kind === 'tools') {
|
||||
return (
|
||||
<ToolCallGroup
|
||||
key={block.key}
|
||||
calls={block.calls}
|
||||
userSentAt={turn.startedAt}
|
||||
active={turn.active}
|
||||
className="message-tool-call"
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (block.kind === 'reasoning') {
|
||||
return (
|
||||
<AgentMessageContent
|
||||
key={block.key}
|
||||
as="details"
|
||||
tone="process"
|
||||
className="design-agent-reasoning"
|
||||
aria-label="思考过程"
|
||||
>
|
||||
<summary>思考过程</summary>
|
||||
<pre>{block.text}</pre>
|
||||
</AgentMessageContent>
|
||||
);
|
||||
}
|
||||
const role = block.kind === 'user' ? 'user' : 'assistant';
|
||||
return (
|
||||
<div key={block.key} className={`message message--${role}`}>
|
||||
<AgentMessageContent tone={tone}>
|
||||
<ChatMarkdownMessage
|
||||
role={role}
|
||||
text={block.text}
|
||||
streaming={block.key === streamingKey}
|
||||
/>
|
||||
</AgentMessageContent>
|
||||
{block.kind === 'user' && block.at > 0 ? (
|
||||
<time
|
||||
className="message-sent-at"
|
||||
dateTime={new Date(directMessageTimestamp(block.at)).toISOString()}
|
||||
title={`发送于 ${new Date(block.at).toLocaleString('zh-CN', { hour12: false })}`}
|
||||
>
|
||||
{formatClockTime(block.at)}
|
||||
</time>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
className="project-supervisor-surface is-direct-codex"
|
||||
aria-label="陶泥儿项目对话"
|
||||
>
|
||||
<div className="project-supervisor-conversation">
|
||||
<header className="project-supervisor-topbar">
|
||||
<span className="project-supervisor-topbar-status" aria-live="polite">
|
||||
<span
|
||||
className={`project-supervisor-topbar-dot${controlBusy ? ' is-busy' : ''}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{controlBusy
|
||||
? '陶泥儿正在处理'
|
||||
: projectWorkspaceStatusForDisplay(workspaceStatus)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="project-supervisor-settings-trigger"
|
||||
aria-label="设置"
|
||||
title="设置"
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
>
|
||||
<Settings size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</header>
|
||||
<div
|
||||
ref={messagesRef}
|
||||
className="message-list project-supervisor-message-list"
|
||||
aria-label="陶泥儿消息"
|
||||
onScroll={onScroll}
|
||||
>
|
||||
{hiddenConversationCount > 0 || hasEarlierConversationMessages ? (
|
||||
<button
|
||||
type="button"
|
||||
className="message-history-more"
|
||||
onClick={onShowEarlierMessages}
|
||||
>
|
||||
{hiddenConversationCount > 0
|
||||
? `显示更早 · 还有 ${hiddenConversationCount} 条对话`
|
||||
: '显示更早的对话'}
|
||||
</button>
|
||||
) : null}
|
||||
{directTurns.map((turn) => {
|
||||
const streamingKey = turn.active
|
||||
? ([...turn.process]
|
||||
.reverse()
|
||||
.find((block) => block.kind === 'assistant')?.key ?? null)
|
||||
: null;
|
||||
return (
|
||||
<Fragment key={turn.key}>
|
||||
{turn.users.map((block) =>
|
||||
renderBlock(turn, block, 'body', streamingKey),
|
||||
)}
|
||||
{turn.process.length > 0 ? (
|
||||
turn.active ? (
|
||||
turn.process.map((block) =>
|
||||
renderBlock(turn, block, 'process', streamingKey),
|
||||
)
|
||||
) : (
|
||||
<details
|
||||
className="message-turn-process"
|
||||
data-testid="turn-process"
|
||||
>
|
||||
<summary>执行过程</summary>
|
||||
<div className="message-turn-process-body">
|
||||
{turn.process.map((block) =>
|
||||
renderBlock(turn, block, 'process', streamingKey),
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
)
|
||||
) : null}
|
||||
{turn.finals.map((block) =>
|
||||
renderBlock(turn, block, 'body', streamingKey),
|
||||
)}
|
||||
{renderTurnUsage(turn)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{directTurnRunning ? (
|
||||
<AgentMessageContent
|
||||
as="section"
|
||||
tone="process"
|
||||
className="project-supervisor-process-card is-active"
|
||||
aria-label="陶泥儿执行过程"
|
||||
aria-live="polite"
|
||||
aria-atomic="false"
|
||||
role="status"
|
||||
data-runtime-owned="true"
|
||||
>
|
||||
<header>
|
||||
<span aria-hidden="true" />
|
||||
<strong>陶泥儿正在处理</strong>
|
||||
{activeTurnStartedAt > 0 ? (
|
||||
<em className="project-supervisor-process-elapsed">
|
||||
{`已耗时 ${
|
||||
formatTurnDuration(
|
||||
Math.max(0, turnUsageNow - activeTurnStartedAt),
|
||||
) ?? '0秒'
|
||||
}`}
|
||||
</em>
|
||||
) : null}
|
||||
</header>
|
||||
</AgentMessageContent>
|
||||
) : null}
|
||||
<form
|
||||
className="project-supervisor-composer is-direct-codex"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (modelValidateInFlightRef.current) return;
|
||||
modelValidateInFlightRef.current = true;
|
||||
setModelValidating(true);
|
||||
void (async () => {
|
||||
try {
|
||||
const ready = modelSelectRef.current
|
||||
? await modelSelectRef.current.ensureUsable()
|
||||
: modelReady;
|
||||
if (ready) onSubmit(event);
|
||||
} finally {
|
||||
modelValidateInFlightRef.current = false;
|
||||
setModelValidating(false);
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
<ComposerTurnQueue
|
||||
turns={queuedTurns}
|
||||
assets={chatProjectAssets}
|
||||
onCancel={onCancelQueuedTurn}
|
||||
/>
|
||||
<ComposerPendingAttachments
|
||||
attachments={attachments}
|
||||
onRemove={onRemoveAttachment}
|
||||
/>
|
||||
<ResourceReferenceInput
|
||||
ref={composerRef}
|
||||
ariaLabel="陶泥儿对话内容"
|
||||
activeVersionId={activeVersionId}
|
||||
versions={versions}
|
||||
assets={chatProjectAssets}
|
||||
projectPath={projectPath}
|
||||
disabled={modelValidating}
|
||||
rows={3}
|
||||
value={chatInput}
|
||||
references={chatReferences}
|
||||
placeholder="描述你的想法,或 @ 引用素材"
|
||||
onChange={onChatInputChange}
|
||||
/>
|
||||
<div className="project-supervisor-composer-controls">
|
||||
<div className="project-supervisor-composer-controls-left">
|
||||
<button
|
||||
type="button"
|
||||
className="project-supervisor-reference-trigger"
|
||||
aria-label="插入素材引用"
|
||||
title="插入素材引用"
|
||||
disabled={controlBusy}
|
||||
onClick={() => composerRef.current?.openPicker()}
|
||||
>
|
||||
<AtSign size={15} aria-hidden="true" />
|
||||
</button>
|
||||
<ComposerAttachmentMenu
|
||||
disabled={controlBusy || modelValidating}
|
||||
onPickFiles={onUploadFiles}
|
||||
onOpenReferencePicker={() => composerRef.current?.openPicker()}
|
||||
/>
|
||||
</div>
|
||||
<div className="project-supervisor-composer-controls-right">
|
||||
<ComposerReasoningEffortSelect disabled={controlBusy} />
|
||||
<ConversationModelSelect
|
||||
ref={modelSelectRef}
|
||||
disabled={controlBusy}
|
||||
onReady={setModelReady}
|
||||
projectPath={projectPath}
|
||||
/>
|
||||
<ComposerVoiceButton
|
||||
disabled={controlBusy || modelValidating}
|
||||
onTranscript={(text) => composerRef.current?.insertText(text)}
|
||||
onNotice={setVoiceNotice}
|
||||
/>
|
||||
{controlBusy ? (
|
||||
<ComposerStopButton
|
||||
cancelling={turnCancelling}
|
||||
onCancel={onCancelTurn}
|
||||
/>
|
||||
) : (
|
||||
submitButton
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{composerNotice || attachmentNotice || voiceNotice ? (
|
||||
<p className="project-supervisor-composer-notice" role="status">
|
||||
{composerNotice || attachmentNotice || voiceNotice}
|
||||
</p>
|
||||
) : null}
|
||||
</form>
|
||||
</div>
|
||||
{settingsOpen ? (
|
||||
<ProjectSupervisorSettingsDialog
|
||||
projectPath={projectPath}
|
||||
currentApprovalLabel={approvalModeLabel(approvalMode)}
|
||||
onOpenApproval={() => setApprovalOpen(true)}
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
{settingsOpen && approvalOpen ? (
|
||||
<ApprovalModeDialog
|
||||
approvalMode={approvalMode}
|
||||
notice={approvalNotice}
|
||||
onSelect={setApprovalMode}
|
||||
onNotice={setApprovalNotice}
|
||||
onClose={() => setApprovalOpen(false)}
|
||||
closeOnEscape={false}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
+1
@@ -5,6 +5,7 @@ import type { DirectCodexUserRuntimeRegionPart } from './DirectCodexUserRuntimeR
|
||||
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)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user