接入客户端已启用Skill候选
从扩展索引读取启用状态并合并内置 Skill 清单 输入提示只展示可用 Skill 并保持取消安全
This commit is contained in:
+112
-75
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user