通过 Tauri command 提供 Skill 候选

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

移除前端内置 Skill 名称硬编码并增加读取命令测试
This commit is contained in:
2026-09-16 11:23:13 +08:00
parent c047543825
commit 71000b6df1
4 changed files with 103 additions and 4 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 {
@@ -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,
@@ -583,6 +583,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 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<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;
@@ -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<string>();
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,
@@ -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: <T>(fn: () => T) => T };
update: (fn: () => void) => void;
} {
const element = screen.getByLabelText('聊天') as HTMLElement & {
__lexicalEditor?: {
getEditorState: () => { read: <T>(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(
<ResourceReferenceInput
onChange={vi.fn()}
assets={assets}
projectPath="C:/project"
ariaLabel="聊天"
/>,
);
// 挂载即查询会让「工作区路径非法时不产生任何后端访问」的边界失效。
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',