接入客户端已启用Skill候选

从扩展索引读取启用状态并合并内置 Skill 清单

输入提示只展示可用 Skill 并保持取消安全
This commit is contained in:
2026-09-15 17:26:25 +08:00
parent 774710452f
commit 1e27cd229d
@@ -583,6 +583,8 @@ function ResourceReferenceEditor({
const skipInitialDraftChangeRef = useRef(false);
const [query, setQuery] = useState<string | null>(null);
const [skillQuery, setSkillQuery] = useState<string | null>(null);
const [clientSkills, setClientSkills] = useState<SkillReference[]>([]);
const skillCatalogRequestedRef = useRef(false);
const [pickerOpen, setPickerOpen] = useState(false);
// Enter 提交要避开候选浮层。用 ref 让命令处理器读到最新状态,不必因为
// query / pickerOpen 变化反复重注册 HIGH 优先级命令。
@@ -604,6 +606,38 @@ function ResourceReferenceEditor({
bottom: number;
width: number;
} | 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;
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 versionsContentSignature = iterationsSignature(versions);
const assetsById = useMemo(
@@ -671,7 +705,7 @@ function ResourceReferenceEditor({
const skillOptions = useMemo(() => {
if (skillQuery === null) return [];
const normalized = skillQuery.trim().toLowerCase();
const allSkills = [...BUILTIN_SKILL_REFERENCES, ...skills];
const allSkills = [...BUILTIN_SKILL_REFERENCES, ...clientSkills, ...skills];
const seen = new Set<string>();
return allSkills
.filter((skill) => {
@@ -685,7 +719,7 @@ function ResourceReferenceEditor({
})
.slice(0, 8)
.map((skill) => new SkillMentionOption(skill));
}, [skillQuery, skills]);
}, [clientSkills, skillQuery, skills]);
const triggerFn = useBasicTypeaheadTriggerMatch('@', {
minLength: 0,
@@ -1091,79 +1125,82 @@ function ResourceReferenceEditor({
const renderMentionMenu: MenuRenderFn<
ResourceMentionOption | SkillMentionOption
> = useCallback((_anchorElementRef, itemProps) => {
const inputRect = rootRef.current?.getBoundingClientRect();
if (!inputRect || itemProps.options.length === 0) {
return null;
}
const viewportPadding = 12;
const menuWidth = Math.min(
Math.max(280, inputRect.width),
Math.min(420, window.innerWidth - viewportPadding * 2),
);
const left = Math.min(
Math.max(viewportPadding, inputRect.left),
Math.max(
viewportPadding,
window.innerWidth - menuWidth - viewportPadding,
),
);
const availableAbove = Math.max(0, inputRect.top - viewportPadding - 8);
const availableBelow = Math.max(
0,
window.innerHeight - inputRect.bottom - viewportPadding - 8,
);
const openAbove = availableBelow < 160 && availableAbove > availableBelow;
const maxHeight = Math.max(
120,
Math.min(240, openAbove ? availableAbove : availableBelow),
);
const top = openAbove
? Math.max(viewportPadding, inputRect.top - maxHeight - 8)
: inputRect.bottom + 8;
return createPortal(
<div
className="resource-reference-menu"
role="listbox"
aria-label="候选引用"
style={{
position: 'fixed',
top: `${top}px`,
left: `${left}px`,
width: `${menuWidth}px`,
maxHeight: `${maxHeight}px`,
}}
>
{itemProps.options.map((option, index) => (
<button
type="button"
role="option"
key={option.key}
ref={(element) => option.setRefElement(element)}
aria-selected={itemProps.selectedIndex === index}
className={
itemProps.selectedIndex === index ? 'is-active' : undefined
}
onMouseEnter={() => itemProps.setHighlightedIndex(index)}
onMouseDown={(event) => event.preventDefault()}
onClick={() => itemProps.selectOptionAndCleanUp(option)}
>
<span>
{'reference' in option
? `@${option.reference.label}`
: `$${option.skill.name}`}
</span>
<small>
{'reference' in option
? option.reference.kind
: (option.skill.description ?? 'Skill')}
</small>
</button>
))}
</div>,
document.body,
);
}, [rootRef]);
> = useCallback(
(_anchorElementRef, itemProps) => {
const inputRect = rootRef.current?.getBoundingClientRect();
if (!inputRect || itemProps.options.length === 0) {
return null;
}
const viewportPadding = 12;
const menuWidth = Math.min(
Math.max(280, inputRect.width),
Math.min(420, window.innerWidth - viewportPadding * 2),
);
const left = Math.min(
Math.max(viewportPadding, inputRect.left),
Math.max(
viewportPadding,
window.innerWidth - menuWidth - viewportPadding,
),
);
const availableAbove = Math.max(0, inputRect.top - viewportPadding - 8);
const availableBelow = Math.max(
0,
window.innerHeight - inputRect.bottom - viewportPadding - 8,
);
const openAbove = availableBelow < 160 && availableAbove > availableBelow;
const maxHeight = Math.max(
120,
Math.min(240, openAbove ? availableAbove : availableBelow),
);
const top = openAbove
? Math.max(viewportPadding, inputRect.top - maxHeight - 8)
: inputRect.bottom + 8;
return createPortal(
<div
className="resource-reference-menu"
role="listbox"
aria-label="候选引用"
style={{
position: 'fixed',
top: `${top}px`,
left: `${left}px`,
width: `${menuWidth}px`,
maxHeight: `${maxHeight}px`,
}}
>
{itemProps.options.map((option, index) => (
<button
type="button"
role="option"
key={option.key}
ref={(element) => option.setRefElement(element)}
aria-selected={itemProps.selectedIndex === index}
className={
itemProps.selectedIndex === index ? 'is-active' : undefined
}
onMouseEnter={() => itemProps.setHighlightedIndex(index)}
onMouseDown={(event) => event.preventDefault()}
onClick={() => itemProps.selectOptionAndCleanUp(option)}
>
<span>
{'reference' in option
? `@${option.reference.label}`
: `$${option.skill.name}`}
</span>
<small>
{'reference' in option
? option.reference.kind
: (option.skill.description ?? 'Skill')}
</small>
</button>
))}
</div>,
document.body,
);
},
[rootRef],
);
const pickerReferences = useMemo(() => {
return scopeReferences.filter((reference) => {