Compare commits

..

22 Commits

Author SHA1 Message Date
k88936 38ad2ce256 抽离项目开发 DirectProject 聊天模块
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Failing after 5m42s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 5m31s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 6m9s
Project CI / Backend tests (pull_request) Failing after 30s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m16s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m54s
Project CI / Repository checks (pull_request) Failing after 10s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 5m32s
Project CI / Frontend tests (pull_request) Failing after 16m6s
Project CI / Native shell tests (pull_request) Successful in 24m45s
Project CI / AI game creator shell web tests (pull_request) Failing after 24m28s
将 DirectProject 表现与 controller 移到 view/project-development/chat

删除运行时 Direct feature flag 与 ProjectSupervisorView 的 Direct 行为分支

迁移 ts-rs 生成契约并删除无意义 generated/index.ts

将钱包入口提升到工作台布局并保留现有 Direct 测试覆盖
2026-09-18 22:06:15 +08:00
k88936 65f8e44a88 拆分 DirectProject 聊天架构文档
记录 DirectProject 独立聊天容器与工作台钱包布局决策

更新实施里程碑、计划和项目上下文,明确 DirectProject 固定属于项目开发工作台

记录代码与生成契约迁移的验收边界
2026-09-18 21:04:23 +08:00
k88936 ade1b7803e 修复 DirectProject 校验清单合并
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 7m3s
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 7m25s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 7m30s
Project CI / Backend tests (pull_request) Failing after 11s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 7m41s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m9s
Project CI / Repository checks (pull_request) Failing after 10s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m40s
Project CI / Frontend tests (pull_request) Failing after 7m45s
Project CI / AI game creator shell web tests (pull_request) Failing after 8m31s
Project CI / Native shell tests (pull_request) Successful in 13m1s
让校验函数返回已读取的项目清单

补齐附件在 Codex 输入转换中的分支;图片 part 已退役,只保留附件 part
2026-09-18 14:27:23 +08:00
k88936 71000b6df1 通过 Tauri command 提供 Skill 候选
从已校验的 AGC Skill manifest 派生 list_agc_skill_catalog 命令

移除前端内置 Skill 名称硬编码并增加读取命令测试
2026-09-18 14:27:23 +08:00
k88936 c047543825 复用 DirectProject 引用摘要构造
提取素材与运行区域摘要 helper

让历史投影和 turn 输入转换共用同一份摘要逻辑
2026-09-18 14:27:21 +08:00
k88936 34b3af1d2a 复用已校验的项目清单
DirectProject wire 转换只读取一次项目 manifest

避免每个素材引用重复 I/O 与解析并缩小校验到解析之间的竞态窗口
2026-09-18 14:26:50 +08:00
k88936 f7cba30b6b 拒绝保留路径段的 Skill 名称
在 DirectProject Skill 校验中拒绝 . 与 ..

阻止 Skill 路径解析通过父目录段逃逸配置根目录
2026-09-18 14:26:50 +08:00
k88936 273f12633c 按字符数限制 Skill 名称
使用 Unicode 字符数量而非 UTF-8 字节长度执行 120 字符上限

让多字节 Skill 名称与资源 ID 的长度校验口径一致
2026-09-18 14:26:50 +08:00
k88936 5f5aa17152 展开引用列表指纹分支
将资源、Skill 与运行区域 key 生成改为显式分支

移除 chatReferenceListKey 的嵌套三元表达式
2026-09-18 14:26:50 +08:00
k88936 3a57d9fbdf 移除未使用的 JSON 类型导入
删除 codex_app_server 中未被直接引用的 serde_json::Value 导入
2026-09-18 14:26:50 +08:00
k88936 382b925ab9 复用引用节点插入逻辑
统一资源与 Skill 候选的 Lexical 节点插入流程

避免两个选择处理器在删除查询和关闭菜单行为上漂移
2026-09-18 14:26:50 +08:00
k88936 c1482012c6 整理引用芯片标题分支
提取资源、Skill 与运行区域标题计算 helper

移除 ResourceReferenceChip 的嵌套三元表达式
2026-09-18 14:26:50 +08:00
k88936 c3a17a6efc 更新Skill提及里程碑状态
标记输入提示实现进入进行中阶段
2026-09-18 14:26:50 +08:00
k88936 1bfdc3a760 接通Skill提及Codex输入
将 canonical Skill part 按原顺序转换为 type skill 输入项

沿用客户端隔离 Skill root 解析受控路径
2026-09-18 14:26:50 +08:00
k88936 1e27cd229d 接入客户端已启用Skill候选
从扩展索引读取启用状态并合并内置 Skill 清单

输入提示只展示可用 Skill 并保持取消安全
2026-09-18 14:26:49 +08:00
k88936 774710452f 校验Skill提及用户输入
为 canonical Skill part 增加名称边界校验

将 Skill 提及保留为 Codex 可识别的 $name 文本
2026-09-18 14:26:49 +08:00
k88936 aaecb82622 增加输入框Skill提及候选
扩展聊天引用模型支持 Skill 节点与 canonical content

复用 Lexical typeahead 增加 $Skill 候选和芯片展示

 Conflicts:
	apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx

 Conflicts:
	apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx

 Conflicts:
	apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx
	apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts
2026-09-18 14:26:49 +08:00
k88936 64ad24ac77 补充DirectProject Skill提及输入规范
更新聊天输入主规范并增加 Skill 提及边界

新增 Skill 提及里程碑与小切片实施计划
2026-09-18 14:26:49 +08:00
k88936 75ec3361dc 限制附件元数据格式与长度
校验 inline 附件名称和媒体类型的控制字符、格式与长度。

共享附件清洗上限常量并补充异常元数据拒绝测试。
2026-09-18 14:12:47 +08:00
k88936 bc1dc868a1 校验已导入附件的项目路径
imported 附件必须携带有效非空的项目相对路径。

failed 附件仍允许无路径,并补充状态路径组合测试。
2026-09-18 14:07:07 +08:00
k88936 50204ff7aa 限制 DirectProject inline 附件数量
为 canonical 附件增加独立的八个条目上限。

补充超限附件拒绝测试,避免绕过素材引用数量门禁。
2026-09-18 14:03:30 +08:00
k88936 70fa160819 清洗附件摘要元数据
wire 投影复用附件名称与媒体类型的安全清洗。

附件项目路径输出归一化结果,避免原始控制字符进入模型提示。
2026-09-18 14:00:00 +08:00
63 changed files with 1952 additions and 1890 deletions
+8
View File
@@ -174,6 +174,14 @@ _Avoid_: mock 先行堆积、前后端各自发散、先做排行榜 UI
## 项目开发对话(DirectProject ## 项目开发对话(DirectProject
**DirectProject 专属聊天模块**:
AGC 普通项目聊天的独立容器,拥有 DirectProject 的聊天状态、运行态订阅、历史读取、发送队列、附件和中止交互,并把聊天投影交给专属表现层渲染;它不承接 Supervisor、Design Agent 或 Planning V2 的运行态。
_Avoid_: 把 DirectProject 作为项目总控聊天的一个布尔分支、把四种 Agent 会话抽象成同一事实源
**项目工作台布局**:
承载本地项目的资源工作区、项目级工具和独立聊天产品路径的外层界面;布局拥有跨面板的账户/钱包入口,聊天模块只负责项目对话,不嵌套账户展示。
_Avoid_: 把钱包入口塞进聊天设置、让聊天组件拥有工作台级账户状态
**项目对话历史**: **项目对话历史**:
AGC 本地项目内 Codex 原始对话条目的持久集合,是聊天展示、工具卡片和线程恢复注入的唯一持久事实源。 AGC 本地项目内 Codex 原始对话条目的持久集合,是聊天展示、工具卡片和线程恢复注入的唯一持久事实源。
_Avoid_: 会话缓存、展示态历史、按 UI 需要另存的对话副本 _Avoid_: 会话缓存、展示态历史、按 UI 需要另存的对话副本
@@ -2960,8 +2960,22 @@ impl CodexAppServerConnection {
codex_app_server_text_prompt(&request) codex_app_server_text_prompt(&request)
.map_err(platform_llm::LlmError::InvalidRequest)? .map_err(platform_llm::LlmError::InvalidRequest)?
}; };
let input = let input = if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
codex_app_server_turn_input(&request, &prompt, &self.inner.workspace_path).await?; if let Some(item) = direct_user_item {
let canonical: DirectCodexUserItem = serde_json::from_value(item.clone())
.map_err(|error| platform_llm::LlmError::InvalidRequest(error.to_string()))?;
direct_codex_user_item_to_codex_turn_input(
&self.inner.workspace_path,
&canonical,
self.inner._skill_roots.as_deref().unwrap_or_default(),
)
.map_err(platform_llm::LlmError::InvalidRequest)?
} else {
codex_app_server_turn_input(&request, &prompt, &self.inner.workspace_path).await?
}
} else {
codex_app_server_turn_input(&request, &prompt, &self.inner.workspace_path).await?
};
let _direct_tool_bridge_turn_guard = let _direct_tool_bridge_turn_guard =
if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
Some( Some(
@@ -10,6 +10,6 @@ pub(crate) use model::{
}; };
pub(crate) use validation::validate_direct_codex_user_item; pub(crate) use validation::validate_direct_codex_user_item;
pub(crate) use wire::{ pub(crate) use wire::{
direct_codex_user_item_to_prompt, direct_codex_user_item_to_response_item, direct_codex_user_item_to_codex_turn_input, direct_codex_user_item_to_prompt,
direct_codex_user_item_to_wire_input, direct_codex_user_item_to_response_item, direct_codex_user_item_to_wire_input,
}; };
@@ -34,6 +34,8 @@ pub(crate) enum DirectCodexUserContentPart {
InputText { text: String }, InputText { text: String },
#[serde(rename = "agc_resource_reference")] #[serde(rename = "agc_resource_reference")]
AgcResourceReference { resource_id: String }, AgcResourceReference { resource_id: String },
#[serde(rename = "agc_skill_reference")]
AgcSkillReference { name: String },
#[serde(rename = "agc_runtime_region_reference")] #[serde(rename = "agc_runtime_region_reference")]
AgcRuntimeRegionReference(DirectCodexUserRuntimeRegionPart), AgcRuntimeRegionReference(DirectCodexUserRuntimeRegionPart),
/// Uploaded project attachment kept inline in canonical content. /// Uploaded project attachment kept inline in canonical content.
@@ -14,7 +14,7 @@ pub(crate) const MAX_DIRECT_CODEX_REFERENCES: usize = 32;
pub(crate) fn validate_direct_codex_user_item( pub(crate) fn validate_direct_codex_user_item(
root: &Path, root: &Path,
item: &DirectCodexUserItem, item: &DirectCodexUserItem,
) -> Result<(), String> { ) -> Result<GameCreationAppManifest, String> {
let DirectCodexUserItem::Message(message) = item; let DirectCodexUserItem::Message(message) = item;
if !matches!(message.role, DirectCodexUserRole::User) { if !matches!(message.role, DirectCodexUserRole::User) {
return Err("DirectProject 只接受 user message item".to_string()); return Err("DirectProject 只接受 user message item".to_string());
@@ -37,6 +37,20 @@ pub(crate) fn validate_direct_codex_user_item(
reference_count = reference_count.saturating_add(1); reference_count = reference_count.saturating_add(1);
validate_resource_id_and_manifest(&manifest, resource_id)?; validate_resource_id_and_manifest(&manifest, resource_id)?;
} }
DirectCodexUserContentPart::AgcSkillReference { name } => {
let name = name.trim();
if name.is_empty()
|| name.chars().count() > 120
|| matches!(name, "." | "..")
|| name.chars().any(|character| {
character.is_control()
|| character.is_whitespace()
|| matches!(character, '/' | '\\' | ':' | '$')
})
{
return Err("引用的 Skill 名称无效,请移除后重新选择".to_string());
}
}
DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => { DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => {
reference_count = reference_count.saturating_add(1); reference_count = reference_count.saturating_add(1);
validate_runtime_region_reference(&manifest, reference)?; validate_runtime_region_reference(&manifest, reference)?;
@@ -84,7 +98,7 @@ pub(crate) fn validate_direct_codex_user_item(
if reference_count > MAX_DIRECT_CODEX_REFERENCES { if reference_count > MAX_DIRECT_CODEX_REFERENCES {
return Err(format!("一次最多引用 {MAX_DIRECT_CODEX_REFERENCES} 个素材")); return Err(format!("一次最多引用 {MAX_DIRECT_CODEX_REFERENCES} 个素材"));
} }
Ok(()) Ok(manifest)
} }
/// 整条 content 是否还有有效输入:任何一段非空白文本、或任何一个非文本 part 都算。 /// 整条 content 是否还有有效输入:任何一段非空白文本、或任何一个非文本 part 都算。
@@ -1,8 +1,10 @@
use super::model::{DirectCodexUserContentPart, DirectCodexUserItem}; use super::model::{
DirectCodexUserContentPart, DirectCodexUserItem, DirectCodexUserRuntimeRegionPart,
};
use super::validation::validate_direct_codex_user_item; use super::validation::validate_direct_codex_user_item;
use crate::agent::{ use crate::agent::{
read_manifest_for_project, sanitize_attachment_local_path, sanitize_attachment_media_type, read_manifest_for_project, sanitize_attachment_local_path, sanitize_attachment_media_type,
sanitize_attachment_name, sanitize_attachment_name,GameCreationAppManifest,
}; };
use serde_json::Value; use serde_json::Value;
use std::path::Path; use std::path::Path;
@@ -54,6 +56,47 @@ fn direct_codex_user_item_to_response_content(
.collect() .collect()
} }
fn resource_reference_summary(
manifest: &GameCreationAppManifest,
resource_id: &str,
) -> Result<String, String> {
let resource_id = resource_id.trim();
let asset = manifest
.assets
.iter()
.find(|asset| asset.id == resource_id)
.ok_or_else(|| "引用的素材已不存在,请移除后重新选择".to_string())?;
let path = sanitize_attachment_local_path(&asset.local_path)
.ok_or_else(|| "引用的素材路径无效,请移除后重新选择".to_string())?;
Ok(format!(
"[素材引用 resourceId={resource_id};项目路径={path}]"
))
}
fn runtime_region_summary(reference: &DirectCodexUserRuntimeRegionPart) -> String {
let resources = reference
.resource_ids
.iter()
.map(|id| id.trim())
.collect::<Vec<_>>()
.join(",");
let mut summary = format!("[运行画面区域:名称={} ", reference.label.trim());
if let Some(run_id) = reference.run_id.as_deref() {
summary.push_str(&format!("运行标识={} ", run_id.trim()));
}
if let Some(role) = reference.element_role.as_deref() {
summary.push_str(&format!("角色={} ", role.trim()));
}
if let Some(text) = reference.text.as_deref() {
summary.push_str(&format!("文本={} ", text.trim()));
}
if !resources.is_empty() {
summary.push_str(&format!("关联素材={resources}"));
}
summary.push(']');
summary
}
/// 将 canonical user item 转为 app-server `turn/start.input` 可接受的文本数组。 /// 将 canonical user item 转为 app-server `turn/start.input` 可接受的文本数组。
/// AGC 私有 part 只在这里投影为安全摘要,canonical item 本身不被修改。 /// AGC 私有 part 只在这里投影为安全摘要,canonical item 本身不被修改。
pub(crate) fn direct_codex_user_item_to_wire_input( pub(crate) fn direct_codex_user_item_to_wire_input(
@@ -68,40 +111,13 @@ pub(crate) fn direct_codex_user_item_to_wire_input(
let text = match part { let text = match part {
DirectCodexUserContentPart::InputText { text } => text.clone(), DirectCodexUserContentPart::InputText { text } => text.clone(),
DirectCodexUserContentPart::AgcResourceReference { resource_id } => { DirectCodexUserContentPart::AgcResourceReference { resource_id } => {
let asset = manifest resource_reference_summary(&manifest, resource_id)?
.assets }
.iter() DirectCodexUserContentPart::AgcSkillReference { name } => {
.find(|asset| asset.id == resource_id.trim()) format!("${}", name.trim())
.ok_or_else(|| "引用的素材已不存在,请移除后重新选择".to_string())?;
let path = sanitize_attachment_local_path(&asset.local_path)
.ok_or_else(|| "引用的素材路径无效,请移除后重新选择".to_string())?;
format!(
"[素材引用 resourceId={};项目路径={path}]",
resource_id.trim()
)
} }
DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => { DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => {
let resources = reference runtime_region_summary(reference)
.resource_ids
.iter()
.map(|id| id.trim())
.collect::<Vec<_>>()
.join(",");
let mut summary = format!("[运行画面区域:名称={} ", reference.label.trim());
if let Some(run_id) = reference.run_id.as_deref() {
summary.push_str(&format!("运行标识={} ", run_id.trim()));
}
if let Some(role) = reference.element_role.as_deref() {
summary.push_str(&format!("角色={} ", role.trim()));
}
if let Some(text) = reference.text.as_deref() {
summary.push_str(&format!("文本={} ", text.trim()));
}
if !resources.is_empty() {
summary.push_str(&format!("关联素材={resources}"));
}
summary.push(']');
summary
} }
DirectCodexUserContentPart::AgcAttachmentReference(reference) => { DirectCodexUserContentPart::AgcAttachmentReference(reference) => {
let name = sanitize_attachment_name(&reference.name); let name = sanitize_attachment_name(&reference.name);
@@ -124,6 +140,66 @@ pub(crate) fn direct_codex_user_item_to_wire_input(
Ok(Value::Array(input)) Ok(Value::Array(input))
} }
pub(crate) fn direct_codex_user_item_to_codex_turn_input(
root: &Path,
item: &DirectCodexUserItem,
skill_roots: &[std::path::PathBuf],
) -> Result<Value, String> {
let manifest = validate_direct_codex_user_item(root, item)?;
let DirectCodexUserItem::Message(message) = item;
let mut input = Vec::with_capacity(message.content.len());
for part in &message.content {
match part {
DirectCodexUserContentPart::InputText { text } => {
input.push(serde_json::json!({ "type": "text", "text": text }));
}
DirectCodexUserContentPart::AgcResourceReference { resource_id } => {
input.push(serde_json::json!({
"type": "text",
"text": resource_reference_summary(&manifest, resource_id)?,
}));
}
DirectCodexUserContentPart::AgcSkillReference { name } => {
let name = name.trim();
let path = skill_roots
.iter()
.map(|root| root.join(name).join("SKILL.md"))
.find(|path| path.is_file())
.ok_or_else(|| "引用的 Skill 当前不可用,请重新选择".to_string())?;
input.push(serde_json::json!({
"type": "skill",
"name": name,
"path": path,
}));
}
DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => {
input.push(serde_json::json!({
"type": "text",
"text": runtime_region_summary(reference),
}));
}
DirectCodexUserContentPart::AgcAttachmentReference(reference) => {
let mut summary = format!(
"[附件:名称={};类型={};大小={} 字节",
reference.name.trim(),
reference.media_type.trim(),
reference.size
);
if !reference.local_path.trim().is_empty() {
summary.push_str(&format!(";项目路径={}", reference.local_path.trim()));
}
summary.push_str(&format!(";状态={}", reference.status.trim()));
summary.push(']');
input.push(serde_json::json!({
"type": "text",
"text": summary,
}));
}
}
}
Ok(Value::Array(input))
}
pub(crate) fn direct_codex_user_item_to_prompt( pub(crate) fn direct_codex_user_item_to_prompt(
root: &Path, root: &Path,
item: &DirectCodexUserItem, item: &DirectCodexUserItem,
@@ -1,4 +1,4 @@
use serde::Deserialize; use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use std::borrow::Cow; use std::borrow::Cow;
use std::collections::BTreeSet; use std::collections::BTreeSet;
@@ -121,6 +121,13 @@ struct AgcSkillManifestEntry {
sha256: String, sha256: String,
} }
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct AgcSkillCatalogEntry {
pub(crate) name: String,
pub(crate) description: String,
}
fn is_safe_skill_relative_path(value: &str) -> bool { fn is_safe_skill_relative_path(value: &str) -> bool {
let path = Path::new(value); let path = Path::new(value);
!value.is_empty() !value.is_empty()
@@ -234,6 +241,21 @@ pub(crate) fn agc_skill_pack_fingerprint() -> Result<String, String> {
Ok(format!("{:x}", Sha256::digest(canonical_manifest.as_ref()))) Ok(format!("{:x}", Sha256::digest(canonical_manifest.as_ref())))
} }
/// 返回当前客户端随 AGC 一起启用的内置 Skill 候选。
///
/// 前端不得复制审核清单;Skill 名称和描述统一从经过校验的资源 manifest 派生。
#[tauri::command]
pub(crate) fn list_agc_skill_catalog() -> Result<Vec<AgcSkillCatalogEntry>, String> {
Ok(validated_skill_pack_manifest()?
.skills
.into_iter()
.map(|entry| AgcSkillCatalogEntry {
name: entry.name,
description: entry.purpose,
})
.collect())
}
pub(crate) fn render_agc_skill_pack_index() -> Result<String, String> { pub(crate) fn render_agc_skill_pack_index() -> Result<String, String> {
let manifest = validated_skill_pack_manifest()?; let manifest = validated_skill_pack_manifest()?;
let mut lines = vec![format!( let mut lines = vec![format!(
@@ -328,6 +350,19 @@ mod tests {
} }
} }
#[test]
fn skill_catalog_is_derived_from_the_validated_manifest() {
let catalog = list_agc_skill_catalog().expect("skill catalog");
assert_eq!(catalog.len(), AGC_SKILL_PACK_EXPECTED_NAMES.len());
for expected_name in AGC_SKILL_PACK_EXPECTED_NAMES {
let entry = catalog
.iter()
.find(|entry| entry.name == expected_name)
.expect("expected bundled skill");
assert!(!entry.description.trim().is_empty());
}
}
#[test] #[test]
fn skill_content_digest_is_stable_across_lf_and_crlf() { fn skill_content_digest_is_stable_across_lf_and_crlf() {
fn digest(bytes: &[u8]) -> String { fn digest(bytes: &[u8]) -> String {
@@ -482,43 +482,6 @@ fn automatic_local_game_projects_root(app: &tauri::AppHandle) -> Result<PathBuf,
.map_err(|error| format!("无法读取 AGC 应用数据目录:{error}")) .map_err(|error| format!("无法读取 AGC 应用数据目录:{error}"))
} }
/// 校验用户选择的项目创建目录。
///
/// 目录必须已经存在(原生目录选择器返回的结果),并且先过 AGC 私有路径门禁:门禁失败时
/// 这里就拒绝,避免项目被建到 AGC 无法加固、后续无法打开的位置。
pub(crate) fn validate_requested_game_project_creation_root(
requested: &str,
) -> Result<PathBuf, String> {
let requested = requested.trim();
let root = Path::new(requested);
if requested.is_empty() || !root.is_absolute() {
return Err("项目创建目录必须是绝对路径".to_string());
}
if project_path_has_control_chars(root) {
return Err("项目创建目录不能包含控制字符".to_string());
}
let metadata = fs::symlink_metadata(root)
.map_err(|error| format!("读取项目创建目录失败:{}: {error}", root.display()))?;
if metadata.file_type().is_symlink() || !metadata.is_dir() {
return Err("项目创建目录必须是普通文件夹".to_string());
}
// 用户选择的外部目录仍走显式的项目根准备:保留 user-selected 范围的一次性修复,
// 同时不放弃 reparse point / 非普通目录的失败关闭。
prepare_game_creator_project_root_for_read(root, true, "项目创建目录")?;
Ok(root.to_path_buf())
}
/// 解析本次建项要使用的根目录:没选就用 AGC 管理的默认目录,选了就用用户指定的目录。
pub(crate) fn resolve_game_project_creation_root(
app: &tauri::AppHandle,
requested: Option<&str>,
) -> Result<PathBuf, String> {
match requested.map(str::trim).filter(|value| !value.is_empty()) {
Some(requested) => validate_requested_game_project_creation_root(requested),
None => automatic_local_game_projects_root(app),
}
}
pub(crate) fn create_automatic_local_game_project_at( pub(crate) fn create_automatic_local_game_project_at(
projects_root: &Path, projects_root: &Path,
requested_name: Option<&str>, requested_name: Option<&str>,
@@ -588,10 +551,9 @@ pub(crate) fn create_automatic_local_game_project(
app: tauri::AppHandle, app: tauri::AppHandle,
name: Option<String>, name: Option<String>,
planning: Option<bool>, planning: Option<bool>,
projects_root: Option<String>,
) -> Result<InitLocalProjectResult, String> { ) -> Result<InitLocalProjectResult, String> {
create_automatic_local_game_project_at( create_automatic_local_game_project_at(
&resolve_game_project_creation_root(&app, projects_root.as_deref())?, &automatic_local_game_projects_root(&app)?,
name.as_deref(), name.as_deref(),
planning.unwrap_or(false), planning.unwrap_or(false),
) )
@@ -802,30 +764,13 @@ pub(crate) fn recent_game_creator_run_trace(root: &Path) -> Option<GameCreationA
serde_json::from_str::<GameCreationAgentRunTrace>(&content).ok() serde_json::from_str::<GameCreationAgentRunTrace>(&content).ok()
} }
/// 目录选择器标题:调用方只能给短标题,其余(超长、含控制字符、空白)一律回退默认文案。
fn pick_project_directory_title(title: Option<&str>) -> &str {
const MAX_TITLE_CHARS: usize = 24;
title
.map(str::trim)
.filter(|value| {
!value.is_empty()
&& value.chars().count() <= MAX_TITLE_CHARS
&& !value.chars().any(char::is_control)
})
.unwrap_or("选择游戏项目目录")
}
#[tauri::command] #[tauri::command]
pub(crate) async fn pick_local_project_directory( pub(crate) async fn pick_local_project_directory(
app: tauri::AppHandle, app: tauri::AppHandle,
initial_path: Option<String>, initial_path: Option<String>,
title: Option<String>,
) -> Result<Option<String>, String> { ) -> Result<Option<String>, String> {
let (sender, receiver) = tokio::sync::oneshot::channel(); let (sender, receiver) = tokio::sync::oneshot::channel();
let mut dialog = app let mut dialog = app.dialog().file().set_title("选择游戏项目目录");
.dialog()
.file()
.set_title(pick_project_directory_title(title.as_deref()));
if let Some(initial_path) = initial_path if let Some(initial_path) = initial_path
.as_deref() .as_deref()
.map(str::trim) .map(str::trim)
@@ -2518,6 +2518,7 @@ fn main() {
pick_client_extension_file, pick_client_extension_file,
pick_client_extension_directory, pick_client_extension_directory,
list_client_extensions, list_client_extensions,
list_agc_skill_catalog,
import_client_extension, import_client_extension,
set_client_extension_enabled, set_client_extension_enabled,
rename_client_extension, rename_client_extension,
@@ -880,9 +880,12 @@ pub(crate) async fn create_automatic_local_game_project_from_template(
template_version: String, template_version: String,
name: Option<String>, name: Option<String>,
planning: Option<bool>, planning: Option<bool>,
projects_root: Option<String>,
) -> Result<InitLocalProjectResult, String> { ) -> Result<InitLocalProjectResult, String> {
let projects_root = crate::resolve_game_project_creation_root(&app, projects_root.as_deref())?; let projects_root = app
.path()
.app_data_dir()
.map(|root| root.join("projects"))
.map_err(|error| format!("无法读取 AGC 应用数据目录:{error}"))?;
let cache_root = template_cache_root(&app)?; let cache_root = template_cache_root(&app)?;
ensure_game_creator_private_directory_tree(&cache_root, "模板库缓存目录")?; ensure_game_creator_private_directory_tree(&cache_root, "模板库缓存目录")?;
let record = let record =
@@ -1549,63 +1549,6 @@ fn automatic_local_game_project_allocates_unique_initialized_workspaces() {
fs::remove_dir_all(projects_root).ok(); fs::remove_dir_all(projects_root).ok();
} }
#[test]
fn requested_project_creation_root_accepts_only_an_absolute_regular_directory() {
let root = unique_project_path();
fs::create_dir_all(&root).expect("create creation-root fixture");
let not_a_directory = root.join("not-a-directory.txt");
fs::write(&not_a_directory, b"x").expect("write file fixture");
assert_eq!(
validate_requested_game_project_creation_root(" ").expect_err("blank root is rejected"),
"项目创建目录必须是绝对路径"
);
assert_eq!(
validate_requested_game_project_creation_root("relative/projects")
.expect_err("relative root is rejected"),
"项目创建目录必须是绝对路径"
);
assert_eq!(
validate_requested_game_project_creation_root(&format!("{}\\pro\nject", root.display()))
.expect_err("control character is rejected"),
"项目创建目录不能包含控制字符"
);
assert_eq!(
validate_requested_game_project_creation_root(&not_a_directory.to_string_lossy())
.expect_err("file root is rejected"),
"项目创建目录必须是普通文件夹"
);
assert_eq!(
validate_requested_game_project_creation_root(&format!(" {}\n", root.display()))
.expect("trimmed directory root is accepted"),
root
);
assert!(
validate_requested_game_project_creation_root(&root.join("missing").to_string_lossy())
.is_err(),
"a not-yet-existing creation root must fail instead of being created silently"
);
fs::remove_dir_all(root).ok();
}
#[test]
fn automatic_local_game_project_creates_inside_the_requested_creation_root() {
let projects_root = unique_project_path();
fs::create_dir_all(&projects_root).expect("create creation-root fixture");
let requested = validate_requested_game_project_creation_root(&projects_root.to_string_lossy())
.expect("valid creation root");
let result = create_automatic_local_game_project_at(&requested, None, false)
.expect("create workspace in requested root");
let project_root = PathBuf::from(&result.project_path);
assert_eq!(project_root.parent(), Some(projects_root.as_path()));
assert!(project_root.join(".agent/manifest.json").is_file());
fs::remove_dir_all(projects_root).ok();
}
#[test] #[test]
fn automatic_local_game_project_accepts_only_a_safe_custom_name() { fn automatic_local_game_project_accepts_only_a_safe_custom_name() {
let projects_root = unique_project_path(); let projects_root = unique_project_path();
File diff suppressed because it is too large Load Diff
@@ -17,15 +17,10 @@ import type {
ProjectAgentRuntimeSummary, ProjectAgentRuntimeSummary,
} from '../../view/project-development'; } from '../../view/project-development';
import type { ProjectManifestSnapshotMetadata } from '../../view/project-development/projectResourceLiveUpdateModel'; import type { ProjectManifestSnapshotMetadata } from '../../view/project-development/projectResourceLiveUpdateModel';
import { import { isAbsoluteProjectPath } from '../project-summary/projectSummary';
isAbsoluteProjectPath,
projectPathHasControlCharacter,
} from '../project-summary/projectSummary';
const RECENT_WORKSPACES_STORAGE_KEY = const RECENT_WORKSPACES_STORAGE_KEY =
'genarrative-ai-game-creator.recent-workspaces.v1'; 'genarrative-ai-game-creator.recent-workspaces.v1';
const PROJECT_CREATION_DIRECTORY_STORAGE_KEY =
'genarrative-ai-game-creator.project-creation-directory.v1';
const SUPERVISOR_CHAT_DRAFT_STORAGE_PREFIX = const SUPERVISOR_CHAT_DRAFT_STORAGE_PREFIX =
'genarrative.supervisor-chat.draft'; 'genarrative.supervisor-chat.draft';
@@ -161,57 +156,6 @@ export function removeRecentWorkspace(path: string) {
return recent; return recent;
} }
/**
* 「项目创建目录」偏好:空串表示沿用 AGC 管理的默认位置(应用数据目录下的 projects)。
*
* 这里只保存用户意图,不是授权凭据:目录授权来自原生目录选择器,并由 Rust 侧私有路径门禁
* 在每次建项时重新复核,所以存储被改坏最坏只是退回默认位置或拿到一次可见的建项失败。
*/
export function normalizeProjectCreationDirectory(value: string) {
const trimmed = value.trim();
if (!trimmed || projectPathHasControlCharacter(trimmed)) {
return '';
}
const withoutTrailingSeparator = trimmed.replace(/[\\/]+$/, '');
// `C:\` 这类盘根只去掉分隔符会变成相对路径 `C:`,必须补回来。
return /^[a-zA-Z]:$/.test(withoutTrailingSeparator)
? `${withoutTrailingSeparator}\\`
: withoutTrailingSeparator;
}
export function readProjectCreationDirectory() {
try {
const raw = window.localStorage.getItem(
PROJECT_CREATION_DIRECTORY_STORAGE_KEY,
);
const parsed: unknown = raw ? JSON.parse(raw) : '';
if (typeof parsed !== 'string') {
return '';
}
const directory = normalizeProjectCreationDirectory(parsed);
return isAbsoluteProjectPath(directory) ? directory : '';
} catch {
return '';
}
}
export function writeProjectCreationDirectory(path: string) {
const directory = normalizeProjectCreationDirectory(path);
try {
if (directory) {
window.localStorage.setItem(
PROJECT_CREATION_DIRECTORY_STORAGE_KEY,
JSON.stringify(directory),
);
} else {
window.localStorage.removeItem(PROJECT_CREATION_DIRECTORY_STORAGE_KEY);
}
} catch {
// WebView storage can be unavailable in restricted test shells.
}
return directory;
}
export function isTransientProjectOpenMessage( export function isTransientProjectOpenMessage(
message: ChatMessage, message: ChatMessage,
projectPath: string, projectPath: string,
@@ -48,7 +48,6 @@ import {
isAbsoluteProjectPath, isAbsoluteProjectPath,
projectPathHasControlCharacter, projectPathHasControlCharacter,
} from '../project-summary/projectSummary'; } from '../project-summary/projectSummary';
import { readProjectCreationDirectory } from './model';
import { resolveSessionPreviewOnProjectOpen } from './sessionPreview'; import { resolveSessionPreviewOnProjectOpen } from './sessionPreview';
/** 首页输入框当前的纯文本(Lexical 编辑器状态 -> 文本);没有输入就返回空串。 */ /** 首页输入框当前的纯文本(Lexical 编辑器状态 -> 文本);没有输入就返回空串。 */
@@ -829,9 +828,6 @@ export function useHomeProjectCreation({
{ {
name: suggestedName, name: suggestedName,
planning: startMode === 'planning', planning: startMode === 'planning',
// 用户在首页选过「项目创建目录」就用它;没选传 null,由 Rust 侧回落到
// AGC 管理的默认位置(应用数据目录下的 projects)。
projectsRoot: readProjectCreationDirectory() || null,
}, },
); );
createdProjectPath = result.projectPath; createdProjectPath = result.projectPath;
@@ -1,81 +0,0 @@
import { useCallback, useRef, useState } from 'react';
import { resolveTauriInvoke } from '../../app/tauri';
import {
readProjectCreationDirectory,
writeProjectCreationDirectory,
} from './model';
/**
* 「项目创建目录」用户偏好。
*
* 默认沿用 AGC 管理的应用数据目录(`<app_data>/projects`);用户改选时必须走原生目录
* 选择器,因为只有它构成"用户显式选择"边界:选择结果当场按用户选择范围加固,后续建项
* 再由 Rust 侧私有路径门禁复核一次。
*/
export function useProjectCreationDirectory() {
const [projectCreationDirectory, setProjectCreationDirectory] = useState(
readProjectCreationDirectory,
);
const [projectCreationDirectoryBusy, setProjectCreationDirectoryBusy] =
useState(false);
const [projectCreationDirectoryStatus, setProjectCreationDirectoryStatus] =
useState('');
const pickInFlightRef = useRef(false);
const pickProjectCreationDirectory = useCallback(async () => {
if (pickInFlightRef.current) {
return;
}
const invoke = resolveTauriInvoke();
if (!invoke) {
setProjectCreationDirectoryStatus('需要在陶泥儿客户端内运行');
return;
}
pickInFlightRef.current = true;
setProjectCreationDirectoryBusy(true);
setProjectCreationDirectoryStatus('正在选择项目创建目录');
try {
const selected = await invoke<string | null>(
'pick_local_project_directory',
{
title: '选择项目创建目录',
...(projectCreationDirectory
? { initialPath: projectCreationDirectory }
: {}),
},
);
if (!selected) {
setProjectCreationDirectoryStatus('已取消');
return;
}
setProjectCreationDirectory(writeProjectCreationDirectory(selected));
setProjectCreationDirectoryStatus('已更新项目创建目录');
} catch (error) {
setProjectCreationDirectoryStatus(
error instanceof Error ? error.message : String(error),
);
} finally {
pickInFlightRef.current = false;
setProjectCreationDirectoryBusy(false);
}
}, [projectCreationDirectory]);
const resetProjectCreationDirectory = useCallback(() => {
writeProjectCreationDirectory('');
setProjectCreationDirectory('');
setProjectCreationDirectoryStatus('已恢复默认位置');
}, []);
return {
projectCreationDirectory,
projectCreationDirectoryBusy,
projectCreationDirectoryStatus,
pickProjectCreationDirectory,
resetProjectCreationDirectory,
};
}
export type ProjectCreationDirectoryController = ReturnType<
typeof useProjectCreationDirectory
>;
@@ -2,7 +2,7 @@ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext
import { $getNodeByKey, type NodeKey } from 'lexical'; import { $getNodeByKey, type NodeKey } from 'lexical';
import { Paperclip, X } from 'lucide-react'; import { Paperclip, X } from 'lucide-react';
import type { DirectCodexUserAttachmentReferencePart } from './generated'; import type { DirectCodexUserAttachmentReferencePart } from '../../view/project-development/chat/generated/DirectCodexUserAttachmentReferencePart';
export function AttachmentReferenceChip({ export function AttachmentReferenceChip({
attachment, attachment,
@@ -9,8 +9,8 @@ import {
} from 'lexical'; } from 'lexical';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import type { DirectCodexUserAttachmentReferencePart } from '../../view/project-development/chat/generated/DirectCodexUserAttachmentReferencePart';
import { AttachmentReferenceChip } from './AttachmentReferenceChip'; import { AttachmentReferenceChip } from './AttachmentReferenceChip';
import type { DirectCodexUserAttachmentReferencePart } from './generated';
export type SerializedAttachmentReferenceNode = Spread< export type SerializedAttachmentReferenceNode = Spread<
{ {
@@ -1,5 +1,4 @@
import { Settings2, ShieldCheck, Wallet, X } from 'lucide-react'; import { Settings2, ShieldCheck, X } from 'lucide-react';
import type { ReactNode } from 'react';
import { useState } from 'react'; import { useState } from 'react';
import { import {
@@ -17,19 +16,16 @@ import { RuntimeConfigDialog } from '../runtime-config/RuntimeConfigDialog';
* 组成: * 组成:
* - 「运行配置」打开既有 `RuntimeConfigDialog`(独立浮层,自己有 backdrop); * - 「运行配置」打开既有 `RuntimeConfigDialog`(独立浮层,自己有 backdrop);
* - 「操作权限」打开由 `ApprovalModeDialog` 提供的选择面板; * - 「操作权限」打开由 `ApprovalModeDialog` 提供的选择面板;
* - 「泥点」直接渲染父级传入的 `walletEntry`(为空则整行不渲染)。
*/ */
export function ProjectSupervisorSettingsDialog({ export function ProjectSupervisorSettingsDialog({
projectPath, projectPath,
currentApprovalLabel, currentApprovalLabel,
onOpenApproval, onOpenApproval,
walletEntry,
onClose, onClose,
}: { }: {
projectPath: string; projectPath: string;
currentApprovalLabel: string; currentApprovalLabel: string;
onOpenApproval: () => void; onOpenApproval: () => void;
walletEntry?: ReactNode;
onClose: () => void; onClose: () => void;
}) { }) {
const [runtimeConfigOpen, setRuntimeConfigOpen] = useState(false); const [runtimeConfigOpen, setRuntimeConfigOpen] = useState(false);
@@ -90,18 +86,6 @@ export function ProjectSupervisorSettingsDialog({
</span> </span>
</span> </span>
</button> </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> </div>
</section> </section>
</div> </div>
File diff suppressed because it is too large Load Diff
@@ -4,6 +4,16 @@ import { X } from 'lucide-react';
import type { ChatReference } from './resourceReferences'; import type { ChatReference } from './resourceReferences';
function chipTitle(reference: ChatReference) {
if (reference.type === 'resource') {
return `${reference.label} · ${reference.kind}`;
}
if (reference.type === 'skill') {
return `${reference.name} · Skill`;
}
return `${reference.label} · 运行区域`;
}
export function ResourceReferenceChip({ export function ResourceReferenceChip({
reference, reference,
nodeKey, nodeKey,
@@ -21,18 +31,19 @@ export function ResourceReferenceChip({
data-runtime-region-reference={ data-runtime-region-reference={
reference.type === 'runtime-region' ? 'true' : undefined reference.type === 'runtime-region' ? 'true' : undefined
} }
contentEditable={false} data-skill-reference-name={
title={ reference.type === 'skill' ? reference.name : undefined
reference.type === 'resource'
? `${reference.label} · ${reference.kind}`
: `${reference.label} · 运行区域`
} }
contentEditable={false}
title={chipTitle(reference)}
> >
<span aria-hidden="true">@</span> <span aria-hidden="true">{reference.type === 'skill' ? '$' : '@'}</span>
<span className="resource-reference-chip-label">{reference.label}</span> <span className="resource-reference-chip-label">
{reference.type === 'skill' ? reference.name : reference.label}
</span>
<button <button
type="button" type="button"
aria-label={`移除引用 ${reference.label}`} aria-label={`移除引用 ${reference.type === 'skill' ? reference.name : reference.label}`}
onMouseDown={(event) => event.preventDefault()} onMouseDown={(event) => event.preventDefault()}
onClick={() => { onClick={() => {
editor.update(() => { editor.update(() => {
@@ -61,6 +61,7 @@ import {
createProjectResourcePreviewRequestId, createProjectResourcePreviewRequestId,
createProjectResourcePreviewScopeId, createProjectResourcePreviewScopeId,
} from '../../services/projectResourcePreviewTransport'; } from '../../services/projectResourcePreviewTransport';
import type { DirectCodexUserContentPart } from '../../view/project-development/chat/generated/DirectCodexUserContentPart';
import { import {
$createAttachmentReferenceNode, $createAttachmentReferenceNode,
$isAttachmentReferenceNode, $isAttachmentReferenceNode,
@@ -73,7 +74,6 @@ import {
writeChatPromptPolishReminderDisabled, writeChatPromptPolishReminderDisabled,
} from './chatPromptPolish'; } from './chatPromptPolish';
import { ChatPromptPolishReminder } from './ChatPromptPolishReminder'; import { ChatPromptPolishReminder } from './ChatPromptPolishReminder';
import type { DirectCodexUserContentPart } from './generated';
import { import {
$createResourceReferenceNode, $createResourceReferenceNode,
$isResourceReferenceNode, $isResourceReferenceNode,
@@ -98,6 +98,7 @@ import {
resourceReferenceMatchesTagSelection, resourceReferenceMatchesTagSelection,
type ResourceReferenceScope, type ResourceReferenceScope,
resourceReferenceTagLibrary, resourceReferenceTagLibrary,
type SkillReference,
} from './resourceReferences'; } from './resourceReferences';
import { usePromptPolish } from './usePromptPolish'; import { usePromptPolish } from './usePromptPolish';
@@ -108,6 +109,7 @@ type ResourceReferenceInputProps = {
onEditorStateChange?: (editorState: EditorState) => void; onEditorStateChange?: (editorState: EditorState) => void;
initialContent?: DirectCodexUserContentPart[]; initialContent?: DirectCodexUserContentPart[];
assets: GameCreationAppAssetManifestEntry[]; assets: GameCreationAppAssetManifestEntry[];
skills?: SkillReference[];
projectPath: string; projectPath: string;
/** /**
* `@` 面板「当前版本素材」页签使用的版本 id。 * `@` 面板「当前版本素材」页签使用的版本 id。
@@ -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 子节点之间补的 * 编辑器节点 → canonical content part**原样透传**:段落分隔(root 子节点之间补的
* `\n`)、软换行、chip 后的分隔空格都各自成 part,不做空白过滤、不与相邻 part 合并。 * `\n`)、软换行、chip 后的分隔空格都各自成 part,不做空白过滤、不与相邻 part 合并。
* 有效输入只在整条 content 上判定(`hasMeaningfulDirectCodexContent` 与 Rust * 有效输入只在整条 content 上判定(`hasMeaningfulDirectCodexContent` 与 Rust
* `validate_direct_codex_user_item` 同口径),前端不替用户改写他输入了什么。 * `validate_direct_codex_user_item` 同口径),前端不替用户改写他输入了什么。
*/ */
function collectDraftParts( function collectDraftParts(
node: LexicalNode, node: LexicalNode,
references: ChatReference[], references: ChatReference[],
@@ -244,8 +256,12 @@ function readDraftFromNodes(): ChatComposerDraft {
const projection = readDraftProjectionFromNodes(); const projection = readDraftProjectionFromNodes();
const labels = new Map( const labels = new Map(
projection.references.map((reference) => [ projection.references.map((reference) => [
reference.type === 'resource' ? reference.resourceId : reference.label, reference.type === 'resource'
`@${reference.label}`, ? reference.resourceId
: reference.type === 'skill'
? reference.name
: reference.label,
reference.type === 'skill' ? `$${reference.name}` : `@${reference.label}`,
]), ]),
); );
const text = projection.content const text = projection.content
@@ -256,7 +272,7 @@ function readDraftFromNodes(): ChatComposerDraft {
? (labels.get(part.resourceId) ?? `@${part.resourceId}`) ? (labels.get(part.resourceId) ?? `@${part.resourceId}`)
: part.type === 'agc_runtime_region_reference' : part.type === 'agc_runtime_region_reference'
? `@${part.label}` ? `@${part.label}`
: `@${part.name}`, : `$${part.name}`,
) )
.join('') .join('')
.trim(); .trim();
@@ -308,7 +324,7 @@ function findDraftMentionToken(line: string, token: string, from: number) {
* 把「文本 + 引用列表」这对外部表示切成编辑区要写的内容。 * 把「文本 + 引用列表」这对外部表示切成编辑区要写的内容。
* *
* 不变量:写入编辑器后,编辑器读回的草稿就是唯一当前值。`collectDraftParts` * 不变量:写入编辑器后,编辑器读回的草稿就是唯一当前值。`collectDraftParts`
* 会把每个 chip 读成一段 `@显示名` 文本,所以 chip 只能内联嵌在文本里同名 token 的位置上, * 会把每个 chip 读成一段 `@显示名` / `$skill-name` 文本,所以 chip 只能内联嵌在文本里同名 token 的位置上,
* 不能另起一段堆在末尾;编辑器本身是唯一 authority,不再通过 props 比较后反复重建。 * 不能另起一段堆在末尾;编辑器本身是唯一 authority,不再通过 props 比较后反复重建。
* *
* 文本里已经没有对应 token 的引用(例如 AI 润色整体改写了文本)内联补到最后一段末尾, * 文本里已经没有对应 token 的引用(例如 AI 润色整体改写了文本)内联补到最后一段末尾,
@@ -320,7 +336,8 @@ function buildDraftSegments(
): DraftBuildSegment[][] { ): DraftBuildSegment[][] {
const pending = references.map((reference) => ({ const pending = references.map((reference) => ({
reference, reference,
token: `@${reference.label}`, token:
reference.type === 'skill' ? `$${reference.name}` : `@${reference.label}`,
used: false, used: false,
})); }));
const lines: DraftBuildSegment[][] = []; const lines: DraftBuildSegment[][] = [];
@@ -393,6 +410,9 @@ function referenceFromContentPart(
const asset = assetsById.get(part.resourceId); const asset = assetsById.get(part.resourceId);
return asset ? resourceReferenceFromAsset(asset, 'asset-picker') : null; return asset ? resourceReferenceFromAsset(asset, 'asset-picker') : null;
} }
if (part.type === 'agc_skill_reference') {
return { type: 'skill', name: part.name };
}
if (part.type === 'agc_runtime_region_reference') { if (part.type === 'agc_runtime_region_reference') {
return { return {
type: 'runtime-region', type: 'runtime-region',
@@ -540,6 +560,7 @@ function ResourceReferenceEditor({
onEditorStateChange, onEditorStateChange,
initialContent, initialContent,
assets, assets,
skills = [],
projectPath, projectPath,
activeVersionId = null, activeVersionId = null,
versions, versions,
@@ -556,14 +577,18 @@ function ResourceReferenceEditor({
const [editor] = useLexicalComposerContext(); const [editor] = useLexicalComposerContext();
const skipInitialDraftChangeRef = useRef(false); const skipInitialDraftChangeRef = useRef(false);
const [query, setQuery] = useState<string | null>(null); const [query, setQuery] = useState<string | null>(null);
const [skillQuery, setSkillQuery] = useState<string | null>(null);
const [builtinSkills, setBuiltinSkills] = useState<SkillReference[]>([]);
const [clientSkills, setClientSkills] = useState<SkillReference[]>([]);
const skillCatalogRequestedRef = useRef(false);
const [pickerOpen, setPickerOpen] = useState(false); const [pickerOpen, setPickerOpen] = useState(false);
// Enter 提交要避开候选浮层。用 ref 让命令处理器读到最新状态,不必因为 // Enter 提交要避开候选浮层。用 ref 让命令处理器读到最新状态,不必因为
// query / pickerOpen 变化反复重注册 HIGH 优先级命令。 // query / pickerOpen 变化反复重注册 HIGH 优先级命令。
const mentionMenuOpenRef = useRef(false); const mentionMenuOpenRef = useRef(false);
const pickerVisibleRef = useRef(false); const pickerVisibleRef = useRef(false);
useEffect(() => { useEffect(() => {
mentionMenuOpenRef.current = query !== null; mentionMenuOpenRef.current = query !== null || skillQuery !== null;
}, [query]); }, [query, skillQuery]);
useEffect(() => { useEffect(() => {
pickerVisibleRef.current = pickerOpen; pickerVisibleRef.current = pickerOpen;
}, [pickerOpen]); }, [pickerOpen]);
@@ -577,6 +602,53 @@ function ResourceReferenceEditor({
bottom: number; bottom: number;
width: number; width: number;
} | null>(null); } | 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 assetsContentSignature = assetsSignature(assets);
const versionsContentSignature = iterationsSignature(versions); const versionsContentSignature = iterationsSignature(versions);
const assetsById = useMemo( const assetsById = useMemo(
@@ -641,11 +713,35 @@ function ResourceReferenceEditor({
.map((reference) => new ResourceMentionOption(reference)); .map((reference) => new ResourceMentionOption(reference));
}, [assetReferences, query]); }, [assetReferences, query]);
const skillOptions = useMemo(() => {
if (skillQuery === null) return [];
const normalized = skillQuery.trim().toLowerCase();
const allSkills = [...builtinSkills, ...clientSkills, ...skills];
const seen = new Set<string>();
return allSkills
.filter((skill) => {
if (seen.has(skill.name)) return false;
seen.add(skill.name);
return (
!normalized ||
skill.name.toLowerCase().includes(normalized) ||
skill.description?.toLowerCase().includes(normalized)
);
})
.slice(0, 8)
.map((skill) => new SkillMentionOption(skill));
}, [builtinSkills, clientSkills, skillQuery, skills]);
const triggerFn = useBasicTypeaheadTriggerMatch('@', { const triggerFn = useBasicTypeaheadTriggerMatch('@', {
minLength: 0, minLength: 0,
maxLength: 64, maxLength: 64,
allowWhitespace: false, allowWhitespace: false,
}); });
const skillTriggerFn = useBasicTypeaheadTriggerMatch('$', {
minLength: 0,
maxLength: 64,
allowWhitespace: false,
});
const insertReferences = useCallback( const insertReferences = useCallback(
(nextReferences: ChatReference[]) => { (nextReferences: ChatReference[]) => {
@@ -771,13 +867,13 @@ function ResourceReferenceEditor({
const currentRefKey = current.references const currentRefKey = current.references
?.map( ?.map(
(reference) => (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('|'); .join('|');
const desiredRefKey = desiredRefs const desiredRefKey = desiredRefs
.map( .map(
(reference) => (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('|'); .join('|');
if (current.text === desiredValue && currentRefKey === desiredRefKey) if (current.text === desiredValue && currentRefKey === desiredRefKey)
@@ -854,30 +950,47 @@ function ResourceReferenceEditor({
[editor, pickerOpen], [editor, pickerOpen],
); );
const insertReferenceNode = useCallback(
(
reference: ChatReference,
textNodeContainingQuery: TextNode | null,
closeMenu: () => void,
) => {
textNodeContainingQuery?.remove();
const selection = $getSelection();
const node = $createResourceReferenceNode(reference);
if ($isRangeSelection(selection)) {
selection.insertNodes([node, $createTextNode(' ')]);
} else {
const paragraph = $createParagraphNode();
paragraph.append(node, $createTextNode(' '));
$getRoot().append(paragraph);
}
closeMenu();
},
[],
);
const handleSelectMention = useCallback( const handleSelectMention = useCallback(
( (
option: ResourceMentionOption, option: ResourceMentionOption,
textNodeContainingQuery: TextNode | null, textNodeContainingQuery: TextNode | null,
closeMenu: () => void, closeMenu: () => void,
) => { ) => {
textNodeContainingQuery?.remove(); insertReferenceNode(option.reference, textNodeContainingQuery, closeMenu);
const selection = $getSelection();
if ($isRangeSelection(selection)) {
selection.insertNodes([
$createResourceReferenceNode(option.reference),
$createTextNode(' '),
]);
} else {
const paragraph = $createParagraphNode();
paragraph.append(
$createResourceReferenceNode(option.reference),
$createTextNode(' '),
);
$getRoot().append(paragraph);
}
closeMenu();
}, },
[], [insertReferenceNode],
);
const handleSelectSkill = useCallback(
(
option: SkillMentionOption,
textNodeContainingQuery: TextNode | null,
closeMenu: () => void,
) => {
insertReferenceNode(option.skill, textNodeContainingQuery, closeMenu);
},
[insertReferenceNode],
); );
// —— C8 AI 润色与发送前提醒 —— // —— C8 AI 润色与发送前提醒 ——
@@ -1017,7 +1130,9 @@ function ResourceReferenceEditor({
acknowledgedDraftKeyRef.current = null; acknowledgedDraftKeyRef.current = null;
}, [resetPromptPolish]); }, [resetPromptPolish]);
const renderMentionMenu: MenuRenderFn<ResourceMentionOption> = useCallback( const renderMentionMenu: MenuRenderFn<
ResourceMentionOption | SkillMentionOption
> = useCallback(
(_anchorElementRef, itemProps) => { (_anchorElementRef, itemProps) => {
const inputRect = rootRef.current?.getBoundingClientRect(); const inputRect = rootRef.current?.getBoundingClientRect();
if (!inputRect || itemProps.options.length === 0) { if (!inputRect || itemProps.options.length === 0) {
@@ -1052,7 +1167,7 @@ function ResourceReferenceEditor({
<div <div
className="resource-reference-menu" className="resource-reference-menu"
role="listbox" role="listbox"
aria-label="候选素材" aria-label="候选引用"
style={{ style={{
position: 'fixed', position: 'fixed',
top: `${top}px`, top: `${top}px`,
@@ -1075,8 +1190,16 @@ function ResourceReferenceEditor({
onMouseDown={(event) => event.preventDefault()} onMouseDown={(event) => event.preventDefault()}
onClick={() => itemProps.selectOptionAndCleanUp(option)} onClick={() => itemProps.selectOptionAndCleanUp(option)}
> >
<span>{option.reference.label}</span> <span>
<small>{option.reference.kind}</small> {'reference' in option
? `@${option.reference.label}`
: `$${option.skill.name}`}
</span>
<small>
{'reference' in option
? option.reference.kind
: (option.skill.description ?? 'Skill')}
</small>
</button> </button>
))} ))}
</div>, </div>,
@@ -1201,7 +1324,22 @@ function ResourceReferenceEditor({
onSelectOption={(option, textNode, closeMenu) => onSelectOption={(option, textNode, closeMenu) =>
handleSelectMention(option, textNode, closeMenu) handleSelectMention(option, textNode, closeMenu)
} }
menuRenderFn={renderMentionMenu} menuRenderFn={
renderMentionMenu as unknown as MenuRenderFn<ResourceMentionOption>
}
anchorClassName="resource-reference-menu-anchor"
preselectFirstItem
/>
<LexicalTypeaheadMenuPlugin<SkillMentionOption>
options={skillOptions}
triggerFn={skillTriggerFn}
onQueryChange={setSkillQuery}
onSelectOption={(option, textNode, closeMenu) =>
handleSelectSkill(option, textNode, closeMenu)
}
menuRenderFn={
renderMentionMenu as unknown as MenuRenderFn<SkillMentionOption>
}
anchorClassName="resource-reference-menu-anchor" anchorClassName="resource-reference-menu-anchor"
preselectFirstItem preselectFirstItem
/> />
@@ -5,7 +5,7 @@
* 依次发出。队列项能在输入盒上方单独取消。这里只放与 React 无关的纯逻辑,便于单测。 * 依次发出。队列项能在输入盒上方单独取消。这里只放与 React 无关的纯逻辑,便于单测。
*/ */
import type { GameCreationAppAssetManifestEntry } from '../../../../../packages/shared/src/contracts/gameCreationApp'; 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'; import { directCodexContentToPromptText } from './resourceReferences';
/** 队列上限:满了以后拒绝入队并给出可读提示,而不是静默丢消息。 */ /** 队列上限:满了以后拒绝入队并给出可读提示,而不是静默丢消息。 */
@@ -1,5 +1,5 @@
import { resolveTauriInvoke } from '../../app/tauri'; import { resolveTauriInvoke } from '../../app/tauri';
import type { DirectCodexUserContentPart } from './generated'; import type { DirectCodexUserContentPart } from '../../view/project-development/chat/generated/DirectCodexUserContentPart';
/** /**
* 「不再提醒」偏好存本机 localStorage,不进 manifest、不进后端。 * 「不再提醒」偏好存本机 localStorage,不进 manifest、不进后端。
@@ -5,13 +5,11 @@
* 入口转发:前端不再自己抄一份形状,字段增删必须改 Rust。 * 入口转发:前端不再自己抄一份形状,字段增删必须改 Rust。
*/ */
export type { export type { DirectThreadConsumeResult } from '../../view/project-development/chat/generated/DirectThreadConsumeResult';
DirectThreadConsumeResult, export type { DirectThreadDeltaKind } from '../../view/project-development/chat/generated/DirectThreadDeltaKind';
DirectThreadDeltaKind, export type { DirectThreadEvent } from '../../view/project-development/chat/generated/DirectThreadEvent';
DirectThreadEvent, export type { DirectThreadFileChange } from '../../view/project-development/chat/generated/DirectThreadFileChange';
DirectThreadFileChange, export type { DirectThreadHistorySlice } from '../../view/project-development/chat/generated/DirectThreadHistorySlice';
DirectThreadHistorySlice, export type { DirectThreadItem } from '../../view/project-development/chat/generated/DirectThreadItem';
DirectThreadItem, export type { DirectThreadRequestKind } from '../../view/project-development/chat/generated/DirectThreadRequestKind';
DirectThreadRequestKind, export type { DirectThreadSubscriptionBootstrap } from '../../view/project-development/chat/generated/DirectThreadSubscriptionBootstrap';
DirectThreadSubscriptionBootstrap,
} from './generated';
@@ -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';

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