From 71000b6df1d5dff7dc6d8c247906783e2c1cb476 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 16 Sep 2026 11:23:13 +0800 Subject: [PATCH] =?UTF-8?q?=E9=80=9A=E8=BF=87=20Tauri=20command=20?= =?UTF-8?q?=E6=8F=90=E4=BE=9B=20Skill=20=E5=80=99=E9=80=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 从已校验的 AGC Skill manifest 派生 list_agc_skill_catalog 命令 移除前端内置 Skill 名称硬编码并增加读取命令测试 --- .../src-tauri/src/agent/skill_pack.rs | 37 +++++++++++++- .../src-tauri/src/main.rs | 1 + .../ResourceReferenceInput.tsx | 20 +++++++- .../tests/resourceReferenceInput.test.tsx | 49 ++++++++++++++++++- 4 files changed, 103 insertions(+), 4 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/skill_pack.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/skill_pack.rs index 5c5f9e602..1d5fc4861 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/skill_pack.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/skill_pack.rs @@ -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 { Ok(format!("{:x}", Sha256::digest(canonical_manifest.as_ref()))) } +/// 返回当前客户端随 AGC 一起启用的内置 Skill 候选。 +/// +/// 前端不得复制审核清单;Skill 名称和描述统一从经过校验的资源 manifest 派生。 +#[tauri::command] +pub(crate) fn list_agc_skill_catalog() -> Result, 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 { 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 { diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 973197ae6..48657522a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2518,6 +2518,7 @@ fn main() { pick_client_extension_file, pick_client_extension_directory, list_client_extensions, + list_agc_skill_catalog, import_client_extension, set_client_extension_enabled, rename_client_extension, diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx index 11f990607..82077a3cf 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx @@ -583,6 +583,7 @@ function ResourceReferenceEditor({ const skipInitialDraftChangeRef = useRef(false); const [query, setQuery] = useState(null); const [skillQuery, setSkillQuery] = useState(null); + const [builtinSkills, setBuiltinSkills] = useState([]); const [clientSkills, setClientSkills] = useState([]); const skillCatalogRequestedRef = useRef(false); const [pickerOpen, setPickerOpen] = useState(false); @@ -613,6 +614,21 @@ function ResourceReferenceEditor({ const invoke = resolveTauriInvoke(); if (!invoke) return; skillCatalogRequestedRef.current = true; + void invoke>( + '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; @@ -705,7 +721,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(); return allSkills .filter((skill) => { @@ -719,7 +735,7 @@ function ResourceReferenceEditor({ }) .slice(0, 8) .map((skill) => new SkillMentionOption(skill)); - }, [clientSkills, skillQuery, skills]); + }, [builtinSkills, clientSkills, skillQuery, skills]); const triggerFn = useBasicTypeaheadTriggerMatch('@', { minLength: 0, diff --git a/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx b/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx index b80e18a42..602127513 100644 --- a/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx @@ -8,7 +8,7 @@ import { within, } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { $getRoot } from 'lexical'; +import { $getRoot, $getSelection, $isRangeSelection } from 'lexical'; import { createRef, StrictMode, useState } from 'react'; import { afterEach, describe, expect, test, vi } from 'vitest'; @@ -103,10 +103,12 @@ function draftResourceIds(draft: ChatComposerDraft | undefined) { */ function composerEditor(): { getEditorState: () => { read: (fn: () => T) => T }; + update: (fn: () => void) => void; } { const element = screen.getByLabelText('聊天') as HTMLElement & { __lexicalEditor?: { getEditorState: () => { read: (fn: () => T) => T }; + update: (fn: () => void) => void; }; }; const editor = element.__lexicalEditor; @@ -114,6 +116,18 @@ function composerEditor(): { return editor; } +/** + * jsdom 里键盘输入不会进入 Lexical,所以直接走编辑器 API 写文本; + * 它触发的是和真实输入同一条更新链路,typeahead 监听器同样会被唤醒。 + */ +function insertComposerText(text: string) { + composerEditor().update(() => { + $getRoot().selectEnd(); + const selection = $getSelection(); + if ($isRangeSelection(selection)) selection.insertText(text); + }); +} + function editorTextSize() { return composerEditor() .getEditorState() @@ -169,6 +183,39 @@ async function insertAssetThroughPicker(ariaLabel: string, optionName: RegExp) { afterEach(cleanup); describe('ResourceReferenceInput', () => { + test('打开 Skill 候选时才向 Tauri command 查询内置 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( + , + ); + + // 挂载即查询会让「工作区路径非法时不产生任何后端访问」的边界失效。 + expect(invoke).not.toHaveBeenCalled(); + + insertComposerText('$'); + + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith('list_agc_skill_catalog'); + }); + } finally { + delete window.__TAURI__; + } + }); + test('运行画面引用的判别指纹带上了绑定素材、版本、元素角色与尺寸', () => { const base: RuntimeRegionReference = { type: 'runtime-region',