通过 Tauri command 提供 Skill 候选
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Failing after 2m53s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Failing after 2m54s
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Failing after 3m1s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Failing after 3m2s
Project CI / Backend tests (pull_request) Failing after 12s
Project CI / AI game creator shell Rust smoke (pull_request) Failing after 1m28s
Project CI / Repository checks (pull_request) Failing after 9s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m20s
Project CI / Frontend tests (pull_request) Failing after 3m52s
Project CI / AI game creator shell web tests (pull_request) Failing after 3m9s
Project CI / Native shell tests (pull_request) Failing after 4m39s

从已校验的 AGC Skill manifest 派生 list_agc_skill_catalog 命令

移除前端内置 Skill 名称硬编码并增加读取命令测试
This commit is contained in:
2026-09-16 11:23:13 +08:00
parent af5b4044d2
commit 8f5eab4da9
4 changed files with 86 additions and 12 deletions
@@ -1,4 +1,4 @@
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::borrow::Cow;
use std::collections::BTreeSet;
@@ -121,6 +121,13 @@ struct AgcSkillManifestEntry {
sha256: String,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct AgcSkillCatalogEntry {
pub(crate) name: String,
pub(crate) description: String,
}
fn is_safe_skill_relative_path(value: &str) -> bool {
let path = Path::new(value);
!value.is_empty()
@@ -234,6 +241,21 @@ pub(crate) fn agc_skill_pack_fingerprint() -> Result<String, String> {
Ok(format!("{:x}", Sha256::digest(canonical_manifest.as_ref())))
}
/// 返回当前客户端随 AGC 一起启用的内置 Skill 候选。
///
/// 前端不得复制审核清单;Skill 名称和描述统一从经过校验的资源 manifest 派生。
#[tauri::command]
pub(crate) fn list_agc_skill_catalog() -> Result<Vec<AgcSkillCatalogEntry>, String> {
Ok(validated_skill_pack_manifest()?
.skills
.into_iter()
.map(|entry| AgcSkillCatalogEntry {
name: entry.name,
description: entry.purpose,
})
.collect())
}
pub(crate) fn render_agc_skill_pack_index() -> Result<String, String> {
let manifest = validated_skill_pack_manifest()?;
let mut lines = vec![format!(
@@ -328,6 +350,19 @@ mod tests {
}
}
#[test]
fn skill_catalog_is_derived_from_the_validated_manifest() {
let catalog = list_agc_skill_catalog().expect("skill catalog");
assert_eq!(catalog.len(), AGC_SKILL_PACK_EXPECTED_NAMES.len());
for expected_name in AGC_SKILL_PACK_EXPECTED_NAMES {
let entry = catalog
.iter()
.find(|entry| entry.name == expected_name)
.expect("expected bundled skill");
assert!(!entry.description.trim().is_empty());
}
}
#[test]
fn skill_content_digest_is_stable_across_lf_and_crlf() {
fn digest(bytes: &[u8]) -> String {
@@ -2655,6 +2655,7 @@ fn main() {
pick_client_extension_file,
pick_client_extension_directory,
list_client_extensions,
list_agc_skill_catalog,
import_client_extension,
set_client_extension_enabled,
rename_client_extension,
@@ -187,15 +187,6 @@ class SkillMentionOption extends MenuOption {
}
}
const BUILTIN_SKILL_REFERENCES: SkillReference[] = [
'agc-browser-playtest',
'agc-client-projection',
'agc-game-production-workflow',
'agc-project-structure',
'agc-web-game-development',
'taonier-art-assets',
].map((name) => ({ type: 'skill', name }));
function appendInputText(content: DirectCodexUserContentPart[], text: string) {
const previous = content[content.length - 1];
if (previous?.type === 'input_text') {
@@ -491,6 +482,7 @@ function ResourceReferenceEditor({
const skipInitialDraftChangeRef = useRef(false);
const [query, setQuery] = useState<string | null>(null);
const [skillQuery, setSkillQuery] = useState<string | null>(null);
const [builtinSkills, setBuiltinSkills] = useState<SkillReference[]>([]);
const [clientSkills, setClientSkills] = useState<SkillReference[]>([]);
const [pickerOpen, setPickerOpen] = useState(false);
// Enter 提交要避开候选浮层。用 ref 让命令处理器读到最新状态,不必因为
@@ -517,6 +509,22 @@ function ResourceReferenceEditor({
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;
@@ -607,7 +615,7 @@ function ResourceReferenceEditor({
const skillOptions = useMemo(() => {
if (skillQuery === null) return [];
const normalized = skillQuery.trim().toLowerCase();
const allSkills = [...BUILTIN_SKILL_REFERENCES, ...clientSkills, ...skills];
const allSkills = [...builtinSkills, ...clientSkills, ...skills];
const seen = new Set<string>();
return allSkills
.filter((skill) => {
@@ -621,7 +629,7 @@ function ResourceReferenceEditor({
})
.slice(0, 8)
.map((skill) => new SkillMentionOption(skill));
}, [clientSkills, skillQuery, skills]);
}, [builtinSkills, clientSkills, skillQuery, skills]);
const triggerFn = useBasicTypeaheadTriggerMatch('@', {
minLength: 0,
@@ -155,6 +155,36 @@ async function insertAssetThroughPicker(ariaLabel: string, optionName: RegExp) {
afterEach(cleanup);
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
value=""
references={[]}
onChange={vi.fn()}
assets={assets}
projectPath="C:/project"
ariaLabel="聊天"
/>,
);
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith('list_agc_skill_catalog');
});
} finally {
delete window.__TAURI__;
}
});
test('运行画面引用的判别指纹带上了绑定素材、版本、元素角色与尺寸', () => {
const base: RuntimeRegionReference = {
type: 'runtime-region',