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 ccdb3d5cf..d88fa5ab8 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,8 @@ function ResourceReferenceEditor({ const skipInitialDraftChangeRef = useRef(false); const [query, setQuery] = useState(null); const [skillQuery, setSkillQuery] = useState(null); + const [clientSkills, setClientSkills] = useState([]); + 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(); 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( -
- {itemProps.options.map((option, index) => ( - - ))} -
, - 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( +
+ {itemProps.options.map((option, index) => ( + + ))} +
, + document.body, + ); + }, + [rootRef], + ); const pickerReferences = useMemo(() => { return scopeReferences.filter((reference) => {