Compare commits

...

16 Commits

Author SHA1 Message Date
k88936 5161c6eafb 修复 DirectProject 校验清单合并
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 5m14s
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 5m42s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 5m36s
Project CI / Backend tests (pull_request) Failing after 15s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 4m37s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m47s
Project CI / Repository checks (pull_request) Failing after 10s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m16s
Project CI / Frontend tests (pull_request) Failing after 3m46s
Project CI / AI game creator shell web tests (pull_request) Failing after 3m48s
Project CI / Native shell tests (pull_request) Successful in 7m11s
让校验函数返回已读取的项目清单

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

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

让历史投影和 turn 输入转换共用同一份摘要逻辑
2026-09-16 19:20:06 +08:00
k88936 939deafb01 复用已校验的项目清单
DirectProject wire 转换只读取一次项目 manifest

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

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

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

移除 chatReferenceListKey 的嵌套三元表达式
2026-09-16 19:20:06 +08:00
k88936 8a763b61f7 移除未使用的 JSON 类型导入
删除 codex_app_server 中未被直接引用的 serde_json::Value 导入
2026-09-16 19:20:06 +08:00
k88936 1c8d818ee0 复用引用节点插入逻辑
统一资源与 Skill 候选的 Lexical 节点插入流程

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

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

沿用客户端隔离 Skill root 解析受控路径
2026-09-16 19:20:05 +08:00
k88936 15ad3815d8 接入客户端已启用Skill候选
从扩展索引读取启用状态并合并内置 Skill 清单

输入提示只展示可用 Skill 并保持取消安全
2026-09-16 19:20:05 +08:00
k88936 aa44f08f24 校验Skill提及用户输入
为 canonical Skill part 增加名称边界校验

将 Skill 提及保留为 Codex 可识别的 $name 文本
2026-09-16 19:20:05 +08:00
k88936 249ed3a6b2 增加输入框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
2026-09-16 19:20:05 +08:00
k88936 f731dad73c 补充DirectProject Skill提及输入规范
更新聊天输入主规范并增加 Skill 提及边界

新增 Skill 提及里程碑与小切片实施计划
2026-09-16 19:11:06 +08:00
15 changed files with 556 additions and 147 deletions
@@ -2840,8 +2840,22 @@ impl CodexAppServerConnection {
codex_app_server_text_prompt(&request) codex_app_server_text_prompt(&request)
.map_err(platform_llm::LlmError::InvalidRequest)? .map_err(platform_llm::LlmError::InvalidRequest)?
}; };
let input = let input = if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
codex_app_server_turn_input(&request, &prompt, &self.inner.workspace_path).await?; if let Some(item) = direct_user_item {
let canonical: DirectCodexUserItem = serde_json::from_value(item.clone())
.map_err(|error| platform_llm::LlmError::InvalidRequest(error.to_string()))?;
direct_codex_user_item_to_codex_turn_input(
&self.inner.workspace_path,
&canonical,
self.inner._skill_roots.as_deref().unwrap_or_default(),
)
.map_err(platform_llm::LlmError::InvalidRequest)?
} else {
codex_app_server_turn_input(&request, &prompt, &self.inner.workspace_path).await?
}
} else {
codex_app_server_turn_input(&request, &prompt, &self.inner.workspace_path).await?
};
let _direct_tool_bridge_turn_guard = let _direct_tool_bridge_turn_guard =
if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
Some( Some(
@@ -11,6 +11,6 @@ pub(crate) use model::{
}; };
pub(crate) use validation::validate_direct_codex_user_item; pub(crate) use validation::validate_direct_codex_user_item;
pub(crate) use wire::{ pub(crate) use wire::{
direct_codex_user_item_to_prompt, direct_codex_user_item_to_response_item, direct_codex_user_item_to_codex_turn_input, direct_codex_user_item_to_prompt,
direct_codex_user_item_to_wire_input, direct_codex_user_item_to_response_item, direct_codex_user_item_to_wire_input,
}; };
@@ -34,6 +34,8 @@ pub(crate) enum DirectCodexUserContentPart {
InputText { text: String }, InputText { text: String },
#[serde(rename = "agc_resource_reference")] #[serde(rename = "agc_resource_reference")]
AgcResourceReference { resource_id: String }, AgcResourceReference { resource_id: String },
#[serde(rename = "agc_skill_reference")]
AgcSkillReference { name: String },
#[serde(rename = "agc_runtime_region_reference")] #[serde(rename = "agc_runtime_region_reference")]
AgcRuntimeRegionReference(DirectCodexUserRuntimeRegionPart), AgcRuntimeRegionReference(DirectCodexUserRuntimeRegionPart),
/// Uploaded project attachment kept inline in canonical content. /// Uploaded project attachment kept inline in canonical content.
@@ -12,7 +12,7 @@ pub(crate) const MAX_DIRECT_CODEX_REFERENCES: usize = 32;
pub(crate) fn validate_direct_codex_user_item( pub(crate) fn validate_direct_codex_user_item(
root: &Path, root: &Path,
item: &DirectCodexUserItem, item: &DirectCodexUserItem,
) -> Result<(), String> { ) -> Result<GameCreationAppManifest, String> {
let DirectCodexUserItem::Message(message) = item; let DirectCodexUserItem::Message(message) = item;
if !matches!(message.role, DirectCodexUserRole::User) { if !matches!(message.role, DirectCodexUserRole::User) {
return Err("DirectProject 只接受 user message item".to_string()); return Err("DirectProject 只接受 user message item".to_string());
@@ -36,6 +36,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)?;
@@ -57,7 +71,7 @@ pub(crate) fn validate_direct_codex_user_item(
if reference_count > MAX_DIRECT_CODEX_REFERENCES { if reference_count > MAX_DIRECT_CODEX_REFERENCES {
return Err(format!("一次最多引用 {MAX_DIRECT_CODEX_REFERENCES} 个素材")); return Err(format!("一次最多引用 {MAX_DIRECT_CODEX_REFERENCES} 个素材"));
} }
Ok(()) Ok(manifest)
} }
pub(crate) fn validate_resource_id_and_manifest( pub(crate) fn validate_resource_id_and_manifest(
@@ -1,6 +1,10 @@
use super::model::{DirectCodexUserContentPart, DirectCodexUserItem}; use super::model::{
DirectCodexUserContentPart, DirectCodexUserItem, DirectCodexUserRuntimeRegionPart,
};
use super::validation::validate_direct_codex_user_item; use super::validation::validate_direct_codex_user_item;
use crate::agent::{read_manifest_for_project, sanitize_attachment_local_path}; use crate::agent::{
read_manifest_for_project, sanitize_attachment_local_path, GameCreationAppManifest,
};
use serde_json::Value; use serde_json::Value;
use std::path::Path; use std::path::Path;
@@ -51,6 +55,47 @@ fn direct_codex_user_item_to_response_content(
.collect() .collect()
} }
fn resource_reference_summary(
manifest: &GameCreationAppManifest,
resource_id: &str,
) -> Result<String, String> {
let resource_id = resource_id.trim();
let asset = manifest
.assets
.iter()
.find(|asset| asset.id == resource_id)
.ok_or_else(|| "引用的素材已不存在,请移除后重新选择".to_string())?;
let path = sanitize_attachment_local_path(&asset.local_path)
.ok_or_else(|| "引用的素材路径无效,请移除后重新选择".to_string())?;
Ok(format!(
"[素材引用 resourceId={resource_id};项目路径={path}]"
))
}
fn runtime_region_summary(reference: &DirectCodexUserRuntimeRegionPart) -> String {
let resources = reference
.resource_ids
.iter()
.map(|id| id.trim())
.collect::<Vec<_>>()
.join(",");
let mut summary = format!("[运行画面区域:名称={} ", reference.label.trim());
if let Some(run_id) = reference.run_id.as_deref() {
summary.push_str(&format!("运行标识={} ", run_id.trim()));
}
if let Some(role) = reference.element_role.as_deref() {
summary.push_str(&format!("角色={} ", role.trim()));
}
if let Some(text) = reference.text.as_deref() {
summary.push_str(&format!("文本={} ", text.trim()));
}
if !resources.is_empty() {
summary.push_str(&format!("关联素材={resources}"));
}
summary.push(']');
summary
}
/// 将 canonical user item 转为 app-server `turn/start.input` 可接受的文本数组。 /// 将 canonical user item 转为 app-server `turn/start.input` 可接受的文本数组。
/// AGC 私有 part 只在这里投影为安全摘要,canonical item 本身不被修改。 /// AGC 私有 part 只在这里投影为安全摘要,canonical item 本身不被修改。
pub(crate) fn direct_codex_user_item_to_wire_input( pub(crate) fn direct_codex_user_item_to_wire_input(
@@ -65,40 +110,13 @@ pub(crate) fn direct_codex_user_item_to_wire_input(
let text = match part { let text = match part {
DirectCodexUserContentPart::InputText { text } => text.clone(), DirectCodexUserContentPart::InputText { text } => text.clone(),
DirectCodexUserContentPart::AgcResourceReference { resource_id } => { DirectCodexUserContentPart::AgcResourceReference { resource_id } => {
let asset = manifest resource_reference_summary(&manifest, resource_id)?
.assets }
.iter() DirectCodexUserContentPart::AgcSkillReference { name } => {
.find(|asset| asset.id == resource_id.trim()) format!("${}", name.trim())
.ok_or_else(|| "引用的素材已不存在,请移除后重新选择".to_string())?;
let path = sanitize_attachment_local_path(&asset.local_path)
.ok_or_else(|| "引用的素材路径无效,请移除后重新选择".to_string())?;
format!(
"[素材引用 resourceId={};项目路径={path}]",
resource_id.trim()
)
} }
DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => { DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => {
let resources = reference runtime_region_summary(reference)
.resource_ids
.iter()
.map(|id| id.trim())
.collect::<Vec<_>>()
.join(",");
let mut summary = format!("[运行画面区域:名称={} ", reference.label.trim());
if let Some(run_id) = reference.run_id.as_deref() {
summary.push_str(&format!("运行标识={} ", run_id.trim()));
}
if let Some(role) = reference.element_role.as_deref() {
summary.push_str(&format!("角色={} ", role.trim()));
}
if let Some(text) = reference.text.as_deref() {
summary.push_str(&format!("文本={} ", text.trim()));
}
if !resources.is_empty() {
summary.push_str(&format!("关联素材={resources}"));
}
summary.push(']');
summary
} }
DirectCodexUserContentPart::AgcAttachmentReference(reference) => { DirectCodexUserContentPart::AgcAttachmentReference(reference) => {
let mut summary = format!( let mut summary = format!(
@@ -120,6 +138,66 @@ pub(crate) fn direct_codex_user_item_to_wire_input(
Ok(Value::Array(input)) Ok(Value::Array(input))
} }
pub(crate) fn direct_codex_user_item_to_codex_turn_input(
root: &Path,
item: &DirectCodexUserItem,
skill_roots: &[std::path::PathBuf],
) -> Result<Value, String> {
let manifest = validate_direct_codex_user_item(root, item)?;
let DirectCodexUserItem::Message(message) = item;
let mut input = Vec::with_capacity(message.content.len());
for part in &message.content {
match part {
DirectCodexUserContentPart::InputText { text } => {
input.push(serde_json::json!({ "type": "text", "text": text }));
}
DirectCodexUserContentPart::AgcResourceReference { resource_id } => {
input.push(serde_json::json!({
"type": "text",
"text": resource_reference_summary(&manifest, resource_id)?,
}));
}
DirectCodexUserContentPart::AgcSkillReference { name } => {
let name = name.trim();
let path = skill_roots
.iter()
.map(|root| root.join(name).join("SKILL.md"))
.find(|path| path.is_file())
.ok_or_else(|| "引用的 Skill 当前不可用,请重新选择".to_string())?;
input.push(serde_json::json!({
"type": "skill",
"name": name,
"path": path,
}));
}
DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => {
input.push(serde_json::json!({
"type": "text",
"text": runtime_region_summary(reference),
}));
}
DirectCodexUserContentPart::AgcAttachmentReference(reference) => {
let mut summary = format!(
"[附件:名称={};类型={};大小={} 字节",
reference.name.trim(),
reference.media_type.trim(),
reference.size
);
if !reference.local_path.trim().is_empty() {
summary.push_str(&format!(";项目路径={}", reference.local_path.trim()));
}
summary.push_str(&format!(";状态={}", reference.status.trim()));
summary.push(']');
input.push(serde_json::json!({
"type": "text",
"text": summary,
}));
}
}
}
Ok(Value::Array(input))
}
pub(crate) fn direct_codex_user_item_to_prompt( pub(crate) fn direct_codex_user_item_to_prompt(
root: &Path, root: &Path,
item: &DirectCodexUserItem, item: &DirectCodexUserItem,
@@ -1,4 +1,4 @@
use serde::Deserialize; use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use std::borrow::Cow; use std::borrow::Cow;
use std::collections::BTreeSet; use std::collections::BTreeSet;
@@ -121,6 +121,13 @@ struct AgcSkillManifestEntry {
sha256: String, sha256: String,
} }
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct AgcSkillCatalogEntry {
pub(crate) name: String,
pub(crate) description: String,
}
fn is_safe_skill_relative_path(value: &str) -> bool { fn is_safe_skill_relative_path(value: &str) -> bool {
let path = Path::new(value); let path = Path::new(value);
!value.is_empty() !value.is_empty()
@@ -234,6 +241,21 @@ pub(crate) fn agc_skill_pack_fingerprint() -> Result<String, String> {
Ok(format!("{:x}", Sha256::digest(canonical_manifest.as_ref()))) Ok(format!("{:x}", Sha256::digest(canonical_manifest.as_ref())))
} }
/// 返回当前客户端随 AGC 一起启用的内置 Skill 候选。
///
/// 前端不得复制审核清单;Skill 名称和描述统一从经过校验的资源 manifest 派生。
#[tauri::command]
pub(crate) fn list_agc_skill_catalog() -> Result<Vec<AgcSkillCatalogEntry>, String> {
Ok(validated_skill_pack_manifest()?
.skills
.into_iter()
.map(|entry| AgcSkillCatalogEntry {
name: entry.name,
description: entry.purpose,
})
.collect())
}
pub(crate) fn render_agc_skill_pack_index() -> Result<String, String> { pub(crate) fn render_agc_skill_pack_index() -> Result<String, String> {
let manifest = validated_skill_pack_manifest()?; let manifest = validated_skill_pack_manifest()?;
let mut lines = vec![format!( let mut lines = vec![format!(
@@ -328,6 +350,19 @@ mod tests {
} }
} }
#[test]
fn skill_catalog_is_derived_from_the_validated_manifest() {
let catalog = list_agc_skill_catalog().expect("skill catalog");
assert_eq!(catalog.len(), AGC_SKILL_PACK_EXPECTED_NAMES.len());
for expected_name in AGC_SKILL_PACK_EXPECTED_NAMES {
let entry = catalog
.iter()
.find(|entry| entry.name == expected_name)
.expect("expected bundled skill");
assert!(!entry.description.trim().is_empty());
}
}
#[test] #[test]
fn skill_content_digest_is_stable_across_lf_and_crlf() { fn skill_content_digest_is_stable_across_lf_and_crlf() {
fn digest(bytes: &[u8]) -> String { fn digest(bytes: &[u8]) -> String {
@@ -2655,6 +2655,7 @@ fn main() {
pick_client_extension_file, pick_client_extension_file,
pick_client_extension_directory, pick_client_extension_directory,
list_client_extensions, list_client_extensions,
list_agc_skill_catalog,
import_client_extension, import_client_extension,
set_client_extension_enabled, set_client_extension_enabled,
rename_client_extension, rename_client_extension,
@@ -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(() => {
@@ -93,6 +93,7 @@ import {
resourceReferenceMatchesTagSelection, resourceReferenceMatchesTagSelection,
type ResourceReferenceScope, type ResourceReferenceScope,
resourceReferenceTagLibrary, resourceReferenceTagLibrary,
type SkillReference,
} from './resourceReferences'; } from './resourceReferences';
import { usePromptPolish } from './usePromptPolish'; import { usePromptPolish } from './usePromptPolish';
@@ -101,6 +102,7 @@ type ResourceReferenceInputProps = {
onEditorStateChange?: (editorState: EditorState) => void; onEditorStateChange?: (editorState: EditorState) => void;
initialContent?: DirectCodexUserContentPart[]; initialContent?: DirectCodexUserContentPart[];
assets: GameCreationAppAssetManifestEntry[]; assets: GameCreationAppAssetManifestEntry[];
skills?: SkillReference[];
projectPath: string; projectPath: string;
/** /**
* `@` 面板「当前版本素材」页签使用的版本 id。 * `@` 面板「当前版本素材」页签使用的版本 id。
@@ -174,6 +176,15 @@ class ResourceMentionOption extends MenuOption {
} }
} }
class SkillMentionOption extends MenuOption {
skill: SkillReference;
constructor(skill: SkillReference) {
super(skill.name);
this.skill = skill;
}
}
function appendInputText(content: DirectCodexUserContentPart[], text: string) { function appendInputText(content: DirectCodexUserContentPart[], text: string) {
const previous = content[content.length - 1]; const previous = content[content.length - 1];
if (previous?.type === 'input_text') { if (previous?.type === 'input_text') {
@@ -275,7 +286,7 @@ function findDraftMentionToken(line: string, token: string, from: number) {
* 把「文本 + 引用列表」这对外部表示切成编辑区要写的内容。 * 把「文本 + 引用列表」这对外部表示切成编辑区要写的内容。
* *
* 不变量:写入编辑器后,编辑器读回的草稿就是唯一当前值。`collectDraftParts` * 不变量:写入编辑器后,编辑器读回的草稿就是唯一当前值。`collectDraftParts`
* 会把每个 chip 读成一段 `@显示名` 文本,所以 chip 只能内联嵌在文本里同名 token 的位置上, * 会把每个 chip 读成一段 `@显示名` / `$skill-name` 文本,所以 chip 只能内联嵌在文本里同名 token 的位置上,
* 不能另起一段堆在末尾;编辑器本身是唯一 authority,不再通过 props 比较后反复重建。 * 不能另起一段堆在末尾;编辑器本身是唯一 authority,不再通过 props 比较后反复重建。
* *
* 文本里已经没有对应 token 的引用(例如 AI 润色整体改写了文本)内联补到最后一段末尾, * 文本里已经没有对应 token 的引用(例如 AI 润色整体改写了文本)内联补到最后一段末尾,
@@ -287,7 +298,8 @@ function buildDraftSegments(
): DraftBuildSegment[][] { ): DraftBuildSegment[][] {
const pending = references.map((reference) => ({ const pending = references.map((reference) => ({
reference, reference,
token: `@${reference.label}`, token:
reference.type === 'skill' ? `$${reference.name}` : `@${reference.label}`,
used: false, used: false,
})); }));
const lines: DraftBuildSegment[][] = []; const lines: DraftBuildSegment[][] = [];
@@ -360,6 +372,9 @@ function referenceFromContentPart(
const asset = assetsById.get(part.resourceId); const asset = assetsById.get(part.resourceId);
return asset ? resourceReferenceFromAsset(asset, 'asset-picker') : null; return asset ? resourceReferenceFromAsset(asset, 'asset-picker') : null;
} }
if (part.type === 'agc_skill_reference') {
return { type: 'skill', name: part.name };
}
if (part.type === 'agc_runtime_region_reference') { if (part.type === 'agc_runtime_region_reference') {
return { return {
type: 'runtime-region', type: 'runtime-region',
@@ -501,6 +516,7 @@ function ResourceReferenceEditor({
onEditorStateChange, onEditorStateChange,
initialContent, initialContent,
assets, assets,
skills = [],
projectPath, projectPath,
activeVersionId = null, activeVersionId = null,
versions, versions,
@@ -517,14 +533,17 @@ function ResourceReferenceEditor({
const [editor] = useLexicalComposerContext(); const [editor] = useLexicalComposerContext();
const skipInitialDraftChangeRef = useRef(false); const skipInitialDraftChangeRef = useRef(false);
const [query, setQuery] = useState<string | null>(null); const [query, setQuery] = useState<string | null>(null);
const [skillQuery, setSkillQuery] = useState<string | null>(null);
const [builtinSkills, setBuiltinSkills] = useState<SkillReference[]>([]);
const [clientSkills, setClientSkills] = useState<SkillReference[]>([]);
const [pickerOpen, setPickerOpen] = useState(false); const [pickerOpen, setPickerOpen] = useState(false);
// Enter 提交要避开候选浮层。用 ref 让命令处理器读到最新状态,不必因为 // Enter 提交要避开候选浮层。用 ref 让命令处理器读到最新状态,不必因为
// query / pickerOpen 变化反复重注册 HIGH 优先级命令。 // query / pickerOpen 变化反复重注册 HIGH 优先级命令。
const mentionMenuOpenRef = useRef(false); const mentionMenuOpenRef = useRef(false);
const pickerVisibleRef = useRef(false); const pickerVisibleRef = useRef(false);
useEffect(() => { useEffect(() => {
mentionMenuOpenRef.current = query !== null; mentionMenuOpenRef.current = query !== null || skillQuery !== null;
}, [query]); }, [query, skillQuery]);
useEffect(() => { useEffect(() => {
pickerVisibleRef.current = pickerOpen; pickerVisibleRef.current = pickerOpen;
}, [pickerOpen]); }, [pickerOpen]);
@@ -538,6 +557,55 @@ function ResourceReferenceEditor({
bottom: number; bottom: number;
width: number; width: number;
} | null>(null); } | null>(null);
useEffect(() => {
const invoke = resolveTauriInvoke();
if (!invoke) return;
let cancelled = false;
void invoke<Array<{ name: string; description: string }>>(
'list_agc_skill_catalog',
)
.then((items) => {
if (cancelled) return;
setBuiltinSkills(
items.map((item) => ({
type: 'skill' as const,
name: item.name,
description: item.description,
})),
);
})
.catch(() => {
if (!cancelled) setBuiltinSkills([]);
});
void invoke<
Array<{
name: string;
extensionType: string;
enabled: boolean;
status: string;
}>
>('list_client_extensions')
.then((items) => {
if (cancelled) return;
setClientSkills(
items
.filter(
(item) =>
item.extensionType === 'skill' &&
item.enabled &&
item.status === 'enabled',
)
.map((item) => ({ type: 'skill' as const, name: item.name })),
);
})
.catch(() => {
if (!cancelled) setClientSkills([]);
});
return () => {
cancelled = true;
};
}, []);
const assetsContentSignature = assetsSignature(assets); const assetsContentSignature = assetsSignature(assets);
const versionsContentSignature = iterationsSignature(versions); const versionsContentSignature = iterationsSignature(versions);
const assetsById = useMemo( const assetsById = useMemo(
@@ -602,11 +670,35 @@ function ResourceReferenceEditor({
.map((reference) => new ResourceMentionOption(reference)); .map((reference) => new ResourceMentionOption(reference));
}, [assetReferences, query]); }, [assetReferences, query]);
const skillOptions = useMemo(() => {
if (skillQuery === null) return [];
const normalized = skillQuery.trim().toLowerCase();
const allSkills = [...builtinSkills, ...clientSkills, ...skills];
const seen = new Set<string>();
return allSkills
.filter((skill) => {
if (seen.has(skill.name)) return false;
seen.add(skill.name);
return (
!normalized ||
skill.name.toLowerCase().includes(normalized) ||
skill.description?.toLowerCase().includes(normalized)
);
})
.slice(0, 8)
.map((skill) => new SkillMentionOption(skill));
}, [builtinSkills, clientSkills, skillQuery, skills]);
const triggerFn = useBasicTypeaheadTriggerMatch('@', { const triggerFn = useBasicTypeaheadTriggerMatch('@', {
minLength: 0, minLength: 0,
maxLength: 64, maxLength: 64,
allowWhitespace: false, allowWhitespace: false,
}); });
const skillTriggerFn = useBasicTypeaheadTriggerMatch('$', {
minLength: 0,
maxLength: 64,
allowWhitespace: false,
});
const insertReferences = useCallback( const insertReferences = useCallback(
(nextReferences: ChatReference[]) => { (nextReferences: ChatReference[]) => {
@@ -774,30 +866,47 @@ function ResourceReferenceEditor({
[editor, pickerOpen], [editor, pickerOpen],
); );
const insertReferenceNode = useCallback(
(
reference: ChatReference,
textNodeContainingQuery: TextNode | null,
closeMenu: () => void,
) => {
textNodeContainingQuery?.remove();
const selection = $getSelection();
const node = $createResourceReferenceNode(reference);
if ($isRangeSelection(selection)) {
selection.insertNodes([node, $createTextNode(' ')]);
} else {
const paragraph = $createParagraphNode();
paragraph.append(node, $createTextNode(' '));
$getRoot().append(paragraph);
}
closeMenu();
},
[],
);
const handleSelectMention = useCallback( const handleSelectMention = useCallback(
( (
option: ResourceMentionOption, option: ResourceMentionOption,
textNodeContainingQuery: TextNode | null, textNodeContainingQuery: TextNode | null,
closeMenu: () => void, closeMenu: () => void,
) => { ) => {
textNodeContainingQuery?.remove(); insertReferenceNode(option.reference, textNodeContainingQuery, closeMenu);
const selection = $getSelection();
if ($isRangeSelection(selection)) {
selection.insertNodes([
$createResourceReferenceNode(option.reference),
$createTextNode(' '),
]);
} else {
const paragraph = $createParagraphNode();
paragraph.append(
$createResourceReferenceNode(option.reference),
$createTextNode(' '),
);
$getRoot().append(paragraph);
}
closeMenu();
}, },
[], [insertReferenceNode],
);
const handleSelectSkill = useCallback(
(
option: SkillMentionOption,
textNodeContainingQuery: TextNode | null,
closeMenu: () => void,
) => {
insertReferenceNode(option.skill, textNodeContainingQuery, closeMenu);
},
[insertReferenceNode],
); );
// —— C8 AI 润色与发送前提醒 —— // —— C8 AI 润色与发送前提醒 ——
@@ -935,74 +1044,81 @@ function ResourceReferenceEditor({
acknowledgedDraftKeyRef.current = null; acknowledgedDraftKeyRef.current = null;
}, [resetPromptPolish]); }, [resetPromptPolish]);
const renderMentionMenu: MenuRenderFn<ResourceMentionOption> = useCallback( const renderMentionMenu: MenuRenderFn<
(_anchorElementRef, itemProps) => { ResourceMentionOption | SkillMentionOption
const inputRect = rootRef.current?.getBoundingClientRect(); > = useCallback((_anchorElementRef, itemProps) => {
if (!inputRect || itemProps.options.length === 0) { const inputRect = rootRef.current?.getBoundingClientRect();
return null; if (!inputRect || itemProps.options.length === 0) {
} return null;
const viewportPadding = 12; }
const menuWidth = Math.min( const viewportPadding = 12;
Math.max(280, inputRect.width), const menuWidth = Math.min(
Math.min(420, window.innerWidth - viewportPadding * 2), Math.max(280, inputRect.width),
); Math.min(420, window.innerWidth - viewportPadding * 2),
const left = Math.min( );
Math.max(viewportPadding, inputRect.left), const left = Math.min(
Math.max( Math.max(viewportPadding, inputRect.left),
viewportPadding, Math.max(
window.innerWidth - menuWidth - viewportPadding, viewportPadding,
), window.innerWidth - menuWidth - viewportPadding,
); ),
const availableAbove = Math.max(0, inputRect.top - viewportPadding - 8); );
const availableBelow = Math.max( const availableAbove = Math.max(0, inputRect.top - viewportPadding - 8);
0, const availableBelow = Math.max(
window.innerHeight - inputRect.bottom - viewportPadding - 8, 0,
); window.innerHeight - inputRect.bottom - viewportPadding - 8,
const openAbove = availableBelow < 160 && availableAbove > availableBelow; );
const maxHeight = Math.max( const openAbove = availableBelow < 160 && availableAbove > availableBelow;
120, const maxHeight = Math.max(
Math.min(240, openAbove ? availableAbove : availableBelow), 120,
); Math.min(240, openAbove ? availableAbove : availableBelow),
const top = openAbove );
? Math.max(viewportPadding, inputRect.top - maxHeight - 8) const top = openAbove
: inputRect.bottom + 8; ? Math.max(viewportPadding, inputRect.top - maxHeight - 8)
return createPortal( : inputRect.bottom + 8;
<div return createPortal(
className="resource-reference-menu" <div
role="listbox" className="resource-reference-menu"
aria-label="候选素材" role="listbox"
style={{ aria-label="候选引用"
position: 'fixed', style={{
top: `${top}px`, position: 'fixed',
left: `${left}px`, top: `${top}px`,
width: `${menuWidth}px`, left: `${left}px`,
maxHeight: `${maxHeight}px`, width: `${menuWidth}px`,
}} maxHeight: `${maxHeight}px`,
> }}
{itemProps.options.map((option, index) => ( >
<button {itemProps.options.map((option, index) => (
type="button" <button
role="option" type="button"
key={option.key} role="option"
ref={(element) => option.setRefElement(element)} key={option.key}
aria-selected={itemProps.selectedIndex === index} ref={(element) => option.setRefElement(element)}
className={ aria-selected={itemProps.selectedIndex === index}
itemProps.selectedIndex === index ? 'is-active' : undefined className={
} itemProps.selectedIndex === index ? 'is-active' : undefined
onMouseEnter={() => itemProps.setHighlightedIndex(index)} }
onMouseDown={(event) => event.preventDefault()} onMouseEnter={() => itemProps.setHighlightedIndex(index)}
onClick={() => itemProps.selectOptionAndCleanUp(option)} onMouseDown={(event) => event.preventDefault()}
> onClick={() => itemProps.selectOptionAndCleanUp(option)}
<span>{option.reference.label}</span> >
<small>{option.reference.kind}</small> <span>
</button> {'reference' in option
))} ? `@${option.reference.label}`
</div>, : `$${option.skill.name}`}
document.body, </span>
); <small>
}, {'reference' in option
[rootRef], ? option.reference.kind
); : (option.skill.description ?? 'Skill')}
</small>
</button>
))}
</div>,
document.body,
);
}, [rootRef]);
const pickerReferences = useMemo(() => { const pickerReferences = useMemo(() => {
return scopeReferences.filter((reference) => { return scopeReferences.filter((reference) => {
@@ -1119,7 +1235,22 @@ function ResourceReferenceEditor({
onSelectOption={(option, textNode, closeMenu) => onSelectOption={(option, textNode, closeMenu) =>
handleSelectMention(option, textNode, closeMenu) handleSelectMention(option, textNode, closeMenu)
} }
menuRenderFn={renderMentionMenu} menuRenderFn={
renderMentionMenu as unknown as MenuRenderFn<ResourceMentionOption>
}
anchorClassName="resource-reference-menu-anchor"
preselectFirstItem
/>
<LexicalTypeaheadMenuPlugin<SkillMentionOption>
options={skillOptions}
triggerFn={skillTriggerFn}
onQueryChange={setSkillQuery}
onSelectOption={(option, textNode, closeMenu) =>
handleSelectSkill(option, textNode, closeMenu)
}
menuRenderFn={
renderMentionMenu as unknown as MenuRenderFn<SkillMentionOption>
}
anchorClassName="resource-reference-menu-anchor" anchorClassName="resource-reference-menu-anchor"
preselectFirstItem preselectFirstItem
/> />
@@ -5,6 +5,7 @@ import type { DirectCodexUserRuntimeRegionPart } from './DirectCodexUserRuntimeR
export type DirectCodexUserContentPart = export type DirectCodexUserContentPart =
| { type: 'input_text'; text: string } | { type: 'input_text'; text: string }
| { type: 'agc_resource_reference'; resourceId: string } | { type: 'agc_resource_reference'; resourceId: string }
| { type: 'agc_skill_reference'; name: string }
| ({ | ({
type: 'agc_runtime_region_reference'; type: 'agc_runtime_region_reference';
} & DirectCodexUserRuntimeRegionPart) } & DirectCodexUserRuntimeRegionPart)
@@ -46,7 +46,16 @@ export type RuntimeRegionReference = {
source: 'runtime-picker'; source: 'runtime-picker';
}; };
export type ChatReference = ResourceReference | RuntimeRegionReference; export type SkillReference = {
type: 'skill';
name: string;
description?: string;
};
export type ChatReference =
| ResourceReference
| RuntimeRegionReference
| SkillReference;
export type ChatComposerDraft = { export type ChatComposerDraft = {
/** Lexical 顺序对应的 canonical user content;这是唯一草稿真相。 */ /** Lexical 顺序对应的 canonical user content;这是唯一草稿真相。 */
@@ -102,6 +111,9 @@ export function directCodexContentToPromptText(
if (part.type === 'agc_resource_reference') { if (part.type === 'agc_resource_reference') {
return `@${labels.get(part.resourceId) ?? part.resourceId}`; return `@${labels.get(part.resourceId) ?? part.resourceId}`;
} }
if (part.type === 'agc_skill_reference') {
return `$${part.name}`;
}
if (part.type === 'agc_runtime_region_reference') { if (part.type === 'agc_runtime_region_reference') {
return `@${part.label}`; return `@${part.label}`;
} }
@@ -125,6 +137,9 @@ export function chatReferenceToContentPart(
if (reference.type === 'resource') { if (reference.type === 'resource') {
return { type: 'agc_resource_reference', resourceId: reference.resourceId }; return { type: 'agc_resource_reference', resourceId: reference.resourceId };
} }
if (reference.type === 'skill') {
return { type: 'agc_skill_reference', name: reference.name };
}
return { return {
type: 'agc_runtime_region_reference', type: 'agc_runtime_region_reference',
label: reference.label, label: reference.label,
@@ -371,6 +386,9 @@ function chatReferenceKey(reference: ChatReference) {
if (reference.type === 'resource') { if (reference.type === 'resource') {
return `resource:${reference.resourceId}:${reference.source}`; return `resource:${reference.resourceId}:${reference.source}`;
} }
if (reference.type === 'skill') {
return `skill:${reference.name}`;
}
return `runtime-region:${runtimeRegionReferenceDiscriminators(reference)}`; return `runtime-region:${runtimeRegionReferenceDiscriminators(reference)}`;
} }
@@ -380,11 +398,15 @@ function chatReferenceKey(reference: ChatReference) {
*/ */
export function chatReferenceListKey(references: ChatReference[]) { export function chatReferenceListKey(references: ChatReference[]) {
return references return references
.map((reference) => .map((reference) => {
reference.type === 'resource' if (reference.type === 'resource') {
? `resource:${reference.resourceId}:${reference.source}:${reference.label}` return `resource:${reference.resourceId}:${reference.source}:${reference.label}`;
: `runtime-region:${runtimeRegionReferenceDiscriminators(reference)}`, }
) if (reference.type === 'skill') {
return `skill:${reference.name}`;
}
return `runtime-region:${runtimeRegionReferenceDiscriminators(reference)}`;
})
.join('\u0001'); .join('\u0001');
} }
@@ -169,6 +169,34 @@ async function insertAssetThroughPicker(ariaLabel: string, optionName: RegExp) {
afterEach(cleanup); afterEach(cleanup);
describe('ResourceReferenceInput', () => { describe('ResourceReferenceInput', () => {
test('从 Tauri command 读取内置 Skill catalog', async () => {
const invoke = vi.fn(async (command: string) => {
if (command === 'list_agc_skill_catalog') {
return [{ name: 'agc-test-skill', description: '测试 Skill' }];
}
if (command === 'list_client_extensions') return [];
throw new Error(`unexpected invoke ${command}`);
});
window.__TAURI__ = { core: { invoke: invoke as never } };
try {
render(
<ResourceReferenceInput
onChange={vi.fn()}
assets={assets}
projectPath="C:/project"
ariaLabel="聊天"
/>,
);
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith('list_agc_skill_catalog');
});
} finally {
delete window.__TAURI__;
}
});
test('运行画面引用的判别指纹带上了绑定素材、版本、元素角色与尺寸', () => { test('运行画面引用的判别指纹带上了绑定素材、版本、元素角色与尺寸', () => {
const base: RuntimeRegionReference = { const base: RuntimeRegionReference = {
type: 'runtime-region', type: 'runtime-region',
@@ -0,0 +1,27 @@
# 【实施计划】DirectProject Skill 提及输入提示
| 字段 | 值 |
| --- | --- |
| Milestone | `docs/project-memory/plans/【里程碑】DirectProject Skill提及输入提示-2026-09-15.md` |
| Status | in-progress |
| Owner | Codex |
## 代码边界
- 前端:`features/project-workspace/ResourceReferenceInput.tsx``ResourceReferenceNode.tsx``resourceReferences.ts`、生成绑定及聊天入口透传。
- Rust`agent/direct_codex_user_item/model.rs``validation.rs``wire.rs``codex_app_server/mod.rs` 与对应测试。
- 文档:父规范与本里程碑/实施计划。
## 小切片顺序
1. 先扩展前端 Skill catalog/节点/草稿 content,保持素材行为不变并补前端测试。
2. 扩展 canonical Rust part 与 ts-rs 绑定,补序列化和失败校验测试。
3. 接通 Codex wire `type: skill` 转换和受控路径解析,补历史/重放测试。
4. 完成入口透传、定向验证和文档证据;每个切片单独中文提交。
## 验证与回滚
- `npm --prefix apps/ai-game-creator-shell run typecheck`
- 相关 Vitest 与 `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml direct_codex`
- `npm run check:encoding``npm run check:doc-index``git diff --check`
- 每个切片只改计划列出的文件;若 Codex wire 协议或 Skill catalog 来源不确定,停在该切片并先更新规范。
@@ -0,0 +1,43 @@
# 【里程碑】DirectProject Skill 提及输入提示
| 字段 | 值 |
| --- | --- |
| Version | 1.0 |
| Status | in-progress |
| Date | 2026-09-15 |
| Parent Spec | `docs/【功能说明】AGC聊天素材引用-2026-09-08.md` |
## 目标
在现有 Lexical `ResourceReferenceInput` 中增加 Codex 风格 `$skill-name` 提及。用户选择 Skill 后,编辑器保留与文本相对顺序一致的原子节点;DirectProject canonical user item 保存稳定 Skill 名称,Rust 只允许当前已启用且 app-server 已发现的 Skill,并在 `turn/start` 转换为 Codex 原生 `type: "skill"` 输入项。
## 范围
- Skill 候选数据从当前 DirectProject 的已启用 Skill catalog 派生。
- `$` typeahead 菜单、键盘选择、鼠标选择、Esc/Enter 交互与现有 `@` 菜单一致。
- Skill inline 节点和 `content[]` 顺序恢复。
- canonical `agc_skill_reference` part 的 ts-rs 类型、Rust 校验、历史写入与 Codex wire 转换。
- 现有素材、运行区域、assistant、附件和工具 activity 行为保持不变。
## 不在范围内
- Skill 导入、启用、禁用、重命名和 app-server `skills/list` 生命周期改造。
- 普通自然语言自动分类 Skill;只支持显式 `$skill-name` 选择。
- Skill 正文预加载、Skill 内容编辑或新的权限/工具能力。
- SpacetimeDB、HTTP API 和 assistant item 协议变更。
## 验收标准
- [ ] 输入 `$` 可显示并过滤可用 Skill,候选项显示名称和描述。
- [ ] 选择 Skill 后插入原子 `$name` chip,文本与素材/运行区域的相对顺序保持不变。
- [ ] canonical user item 只保存 Skill 稳定名称,不保存正文、凭据或宿主私密路径。
- [ ] Rust 拒绝未知、禁用、未发现或名称非法的 Skill;失败时不写历史、不启动回合。
- [ ] 合法 Skill 在 Codex wire input 中生成 `type: "skill"``name`、受控 `path`,顺序与 canonical content 一致。
- [ ] 无 Skill 的旧消息、素材引用和标准 `response_item` 读取行为不变。
## 证据要求
- 前端:Lexical 草稿顺序、候选过滤、chip 原子性与 `$`/`@` 共存测试。
- Rust:模型序列化、Skill catalog 校验、wire 转换、失败关闭和历史重放测试。
- 运行时:DirectProject app-server smoke(环境可用时)。
- 门禁:相关 Vitest、AGC Rust 定向测试、`npm run check:encoding``npm run check:doc-index``git diff --check`
@@ -2,7 +2,7 @@
更新时间:2026-09-08 更新时间:2026-09-08
AGC 聊天输入框支持以结构化引用标记当前项目已登记素材。输入 `@` 会按素材名称、资源 ID 和类型过滤候选项;也可以点击输入框右侧的 `@` 按钮打开素材选择面板。 AGC 聊天输入框支持以结构化引用标记当前项目已登记素材,并提供 Codex 风格的 Skill 提及。输入 `@` 会按素材名称、资源 ID 和类型过滤候选项;输入 `$` 会按当前 DirectProject 可用 Skill 名称过滤候选项;也可以点击输入框右侧的 `@` 按钮打开素材选择面板。
素材选择面板支持缩略图、名称或资源 ID 搜索、类型筛选和多选;面板顶部有两个页签: 素材选择面板支持缩略图、名称或资源 ID 搜索、类型筛选和多选;面板顶部有两个页签:
@@ -11,7 +11,7 @@ AGC 聊天输入框支持以结构化引用标记当前项目已登记素材。
两个页签各自持有独立的搜索与类型筛选状态,互不影响,也不与资源画布筛选联动。当前版本取 `ResourceReferenceInput``activeVersionId`;未传或传 `null` 时回退到 manifest `versions[]` 中最新的那个版本。版本不存在或该版本没有绑定素材时页签显示空态,不合成资源卡;绑定指向已删除资源(悬空绑定)时按资源 `id` 过滤掉。 两个页签各自持有独立的搜索与类型筛选状态,互不影响,也不与资源画布筛选联动。当前版本取 `ResourceReferenceInput``activeVersionId`;未传或传 `null` 时回退到 manifest `versions[]` 中最新的那个版本。版本不存在或该版本没有绑定素材时页签显示空态,不合成资源卡;绑定指向已删除资源(悬空绑定)时按资源 `id` 过滤掉。
确认后素材以 `@素材名` 芯片插入编辑器,用户可以在芯片前后继续编辑自然语言,也可以单独删除芯片。芯片内部保存稳定 `resourceId`,展示名称只用于界面,不参与引用解析资源改名后,编辑区已有芯片与候选列表都会按 `resourceId` 刷新成 manifest 的最新显示名,并同步回父级草稿。 确认后素材以 `@素材名` 芯片插入编辑器,Skill 以 `$skill-name` 芯片插入编辑器;用户可以在芯片前后继续编辑自然语言,也可以单独删除芯片。素材芯片内部保存稳定 `resourceId`,展示名称只用于界面,不参与引用解析。Skill 芯片保存稳定 Skill 名称,发送时由 Rust 根据当前 DirectProject 已启用 Skill 清单解析为 Codex 原生 `type: "skill"` 输入项。资源改名后,编辑区已有芯片与候选列表都会按 `resourceId` 刷新成 manifest 的最新显示名,并同步回父级草稿。
提交时前端把 Lexical 草稿直接编码为受限 Response API user `message` item`input_text` 与 AGC 引用 part 按编辑顺序内联在同一个 `content[]` 中。资源引用只携带稳定 `resourceId`;运行画面引用携带区域语义摘要及关联资源 ID。Rust 是唯一 schema source(通过 `ts-rs` 生成 TypeScript 绑定),在发起回合前完成 item 白名单、字段边界、manifest 归属和路径安全校验;校验失败时本轮不持久化、不发送。通过校验的 canonical item 以 `response_item` envelope 写入项目历史,随后由 Rust 将 AGC part 临时转换为 Codex 可接受的 `input_text`,保持原始 content 顺序。已有标准 `response_item` 原样读取与复用;旧 legacy conversation 行不再提供 fallback。 提交时前端把 Lexical 草稿直接编码为受限 Response API user `message` item`input_text` 与 AGC 引用 part 按编辑顺序内联在同一个 `content[]` 中。资源引用只携带稳定 `resourceId`;运行画面引用携带区域语义摘要及关联资源 ID。Rust 是唯一 schema source(通过 `ts-rs` 生成 TypeScript 绑定),在发起回合前完成 item 白名单、字段边界、manifest 归属和路径安全校验;校验失败时本轮不持久化、不发送。通过校验的 canonical item 以 `response_item` envelope 写入项目历史,随后由 Rust 将 AGC part 临时转换为 Codex 可接受的 `input_text`,保持原始 content 顺序。已有标准 `response_item` 原样读取与复用;旧 legacy conversation 行不再提供 fallback。
@@ -21,6 +21,7 @@ AGC 聊天输入框支持以结构化引用标记当前项目已登记素材。
- 三个聊天入口共用 `ResourceReferenceInput` - 三个聊天入口共用 `ResourceReferenceInput`
- 输入 `@` 触发候选,支持键盘选择和 Esc 关闭; - 输入 `@` 触发候选,支持键盘选择和 Esc 关闭;
- 输入 `$` 触发 Skill 候选,支持键盘选择和 Esc 关闭;Skill 候选只显示当前 DirectProject 已启用且已由 app-server 发现的 Skill
- `@` 按钮打开素材选择面板; - `@` 按钮打开素材选择面板;
- 支持搜索、类型筛选和多选; - 支持搜索、类型筛选和多选;
- 素材芯片可插入、编辑和删除; - 素材芯片可插入、编辑和删除;
@@ -32,3 +33,4 @@ AGC 聊天输入框支持以结构化引用标记当前项目已登记素材。
- 素材选择面板的「当前版本素材 / 全部画布素材」两个页签与独立筛选、搜索状态; - 素材选择面板的「当前版本素材 / 全部画布素材」两个页签与独立筛选、搜索状态;
- 资源改名后引用芯片与候选列表的显示名自动刷新; - 资源改名后引用芯片与候选列表的显示名自动刷新;
- 切换 / 重开会话恢复草稿后光标落在文本末尾,引用按原 content 顺序恢复为 inline 芯片。 - 切换 / 重开会话恢复草稿后光标落在文本末尾,引用按原 content 顺序恢复为 inline 芯片。
- Skill 提及按原 content 顺序恢复为 inline 芯片;未知、禁用或未发现 Skill 在发送前失败关闭,不写入历史、不启动回合。