From fa1023c4831e30e9d7496e46f183bec88f858e44 Mon Sep 17 00:00:00 2001 From: 7418 <7418@openclaw.ai> Date: Wed, 25 Feb 2026 23:30:45 +0800 Subject: [PATCH] feat: add AI semantic search for skill matching in / command popover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New API route: src/app/api/skills/search/route.ts - POST endpoint receives query + skill list + model alias - resolveApiConfig() resolves credentials: active provider → settings → env - Supports Anthropic direct, OpenRouter (Bearer auth), custom base URLs - Bedrock/Vertex silently return empty results (unsupported) - 5s hard timeout, all errors return { suggestions: [] } - LLM returns JSON array of matching skill names, validated against input - Modified: src/components/chat/MessageInput.tsx - Extend substring matching to search both label and description fields - Add debounced AI search (500ms) when skill mode has < 2 non-built-in substring matches and filter length >= 2 characters - AbortController cancellation on new input or popover close - "AI Suggested" section with BrainIcon and spinner in popover UI - Unified keyboard navigation across filteredItems + aiSuggestions using allDisplayedItems for ArrowUp/Down, Enter/Tab, and visibility - Modified: src/i18n/en.ts, src/i18n/zh.ts - Added 'messageInput.aiSuggested' translation key (EN/ZH) Co-Authored-By: Claude Opus 4.6 --- src/app/api/skills/search/route.ts | 173 +++++++++++++++++++++++++++ src/components/chat/MessageInput.tsx | 158 +++++++++++++++++++++--- src/i18n/en.ts | 1 + src/i18n/zh.ts | 1 + 4 files changed, 319 insertions(+), 14 deletions(-) create mode 100644 src/app/api/skills/search/route.ts diff --git a/src/app/api/skills/search/route.ts b/src/app/api/skills/search/route.ts new file mode 100644 index 0000000..f49e79d --- /dev/null +++ b/src/app/api/skills/search/route.ts @@ -0,0 +1,173 @@ +import { NextResponse } from 'next/server'; +import { getActiveProvider, getSetting } from '@/lib/db'; + +interface SkillInfo { + name: string; + description: string; +} + +interface SearchRequest { + query: string; + skills: SkillInfo[]; + model?: string; +} + +// Model alias -> full model ID +const MODEL_MAP: Record = { + sonnet: 'claude-sonnet-4-20250514', + opus: 'claude-opus-4-20250514', + haiku: 'claude-haiku-4-20250414', +}; + +interface ApiConfig { + supported: boolean; + url?: string; + headers?: Record; + model?: string; +} + +function resolveApiConfig(model?: string): ApiConfig { + const provider = getActiveProvider(); + + // Bedrock/Vertex don't support direct API calls + if (provider?.provider_type === 'bedrock' || provider?.provider_type === 'vertex') { + return { supported: false }; + } + + // Resolve API key: provider -> settings -> env + let apiKey = provider?.api_key || ''; + if (!apiKey) { + apiKey = getSetting('anthropic_auth_token') || ''; + } + + // Check extra_env overrides + let extraEnv: Record = {}; + if (provider?.extra_env) { + try { + extraEnv = JSON.parse(provider.extra_env); + } catch { + // ignore + } + } + + if (!apiKey && extraEnv.ANTHROPIC_API_KEY) { + apiKey = extraEnv.ANTHROPIC_API_KEY; + } + if (!apiKey) { + apiKey = process.env.ANTHROPIC_API_KEY || ''; + } + + if (!apiKey) { + return { supported: false }; + } + + // Resolve base URL + let baseUrl = provider?.base_url || ''; + if (extraEnv.ANTHROPIC_BASE_URL) { + baseUrl = extraEnv.ANTHROPIC_BASE_URL; + } + if (!baseUrl) { + baseUrl = process.env.ANTHROPIC_BASE_URL || 'https://api.anthropic.com'; + } + + // Build Messages API URL + const messagesUrl = `${baseUrl.replace(/\/+$/, '')}/v1/messages`; + + // Resolve model + const modelAlias = model || 'haiku'; + const modelId = MODEL_MAP[modelAlias] || MODEL_MAP.haiku; + + // Build headers: OpenRouter uses Bearer auth, others use x-api-key + const isOpenRouter = baseUrl.includes('openrouter.ai'); + const headers: Record = { + 'Content-Type': 'application/json', + 'anthropic-version': '2023-06-01', + }; + if (isOpenRouter) { + headers['Authorization'] = `Bearer ${apiKey}`; + } else { + headers['x-api-key'] = apiKey; + } + + return { supported: true, url: messagesUrl, headers, model: modelId }; +} + +export async function POST(request: Request) { + try { + const body = (await request.json()) as SearchRequest; + const { query, skills, model } = body; + + if (!query || !skills || skills.length === 0) { + return NextResponse.json({ suggestions: [] }); + } + + const config = resolveApiConfig(model); + if (!config.supported || !config.url || !config.headers) { + return NextResponse.json({ suggestions: [] }); + } + + // Build skill list for prompt (truncate descriptions to 100 chars) + const skillList = skills + .map((s) => { + const desc = s.description ? s.description.slice(0, 100) : ''; + return `- ${s.name}: ${desc}`; + }) + .join('\n'); + + const systemPrompt = + 'You are a skill search engine. Given a list of available skills and a user query, return the most relevant skill names that match the user\'s intent. Return ONLY a JSON array of skill names (strings), up to 5 results. No explanation, no markdown, just the JSON array.'; + + const userMessage = `Available skills:\n${skillList}\n\nUser query: "${query}"\n\nReturn the matching skill names as a JSON array:`; + + // 5 second hard timeout + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 5000); + + try { + const res = await fetch(config.url, { + method: 'POST', + headers: config.headers, + signal: controller.signal, + body: JSON.stringify({ + model: config.model, + max_tokens: 200, + system: systemPrompt, + messages: [{ role: 'user', content: userMessage }], + }), + }); + + clearTimeout(timeout); + + if (!res.ok) { + return NextResponse.json({ suggestions: [] }); + } + + const data = await res.json(); + const text = data.content?.[0]?.text || ''; + + // Extract JSON array from response + const match = text.match(/\[[\s\S]*\]/); + if (!match) { + return NextResponse.json({ suggestions: [] }); + } + + const parsed = JSON.parse(match[0]); + if (!Array.isArray(parsed)) { + return NextResponse.json({ suggestions: [] }); + } + + // Filter to only valid skill names + const validNames = new Set(skills.map((s) => s.name)); + const suggestions = parsed + .filter((name: unknown) => typeof name === 'string' && validNames.has(name)) + .slice(0, 5); + + return NextResponse.json({ suggestions }); + } catch { + clearTimeout(timeout); + return NextResponse.json({ suggestions: [] }); + } + } catch { + return NextResponse.json({ suggestions: [] }); + } +} diff --git a/src/components/chat/MessageInput.tsx b/src/components/chat/MessageInput.tsx index b3fbec1..9e7fc97 100644 --- a/src/components/chat/MessageInput.tsx +++ b/src/components/chat/MessageInput.tsx @@ -351,6 +351,10 @@ export function MessageInput({ const [badge, setBadge] = useState(null); const [dynamicModels, setDynamicModels] = useState<{ value: string; label: string }[]>(DEFAULT_MODEL_OPTIONS); const [activeProviderName, setActiveProviderName] = useState(null); + const [aiSuggestions, setAiSuggestions] = useState([]); + const [aiSearchLoading, setAiSearchLoading] = useState(false); + const aiSearchAbortRef = useRef(null); + const aiSearchTimerRef = useRef | null>(null); // Fetch models from API const fetchModels = useCallback(() => { @@ -442,6 +446,17 @@ export function MessageInput({ setPopoverFilter(''); setSelectedIndex(0); setTriggerPos(null); + // Clean up AI search state + setAiSuggestions([]); + setAiSearchLoading(false); + if (aiSearchTimerRef.current) { + clearTimeout(aiSearchTimerRef.current); + aiSearchTimerRef.current = null; + } + if (aiSearchAbortRef.current) { + aiSearchAbortRef.current.abort(); + aiSearchAbortRef.current = null; + } }, []); // Remove active badge @@ -644,24 +659,127 @@ export function MessageInput({ setInputValue(''); }, [inputValue, onSend, onCommand, disabled, isStreaming, closePopover, badge]); + const filteredItems = popoverItems.filter((item) => { + const q = popoverFilter.toLowerCase(); + return item.label.toLowerCase().includes(q) + || (item.description || '').toLowerCase().includes(q); + }); + + // Debounced AI semantic search when substring results are insufficient + const nonBuiltInFilteredCount = filteredItems.filter(i => !i.builtIn).length; + useEffect(() => { + // Only trigger for skill mode with enough input and few substring matches + if (popoverMode !== 'skill' || popoverFilter.length < 2 || nonBuiltInFilteredCount >= 2) { + setAiSuggestions([]); + setAiSearchLoading(false); + if (aiSearchTimerRef.current) { + clearTimeout(aiSearchTimerRef.current); + aiSearchTimerRef.current = null; + } + if (aiSearchAbortRef.current) { + aiSearchAbortRef.current.abort(); + aiSearchAbortRef.current = null; + } + return; + } + + // Cancel previous timer and request + if (aiSearchTimerRef.current) { + clearTimeout(aiSearchTimerRef.current); + } + if (aiSearchAbortRef.current) { + aiSearchAbortRef.current.abort(); + } + + setAiSearchLoading(true); + + aiSearchTimerRef.current = setTimeout(async () => { + const abortController = new AbortController(); + aiSearchAbortRef.current = abortController; + + try { + // Collect non-built-in skills for AI search + const skillsPayload = popoverItems + .filter(i => !i.builtIn) + .map(i => ({ name: i.label, description: (i.description || '').slice(0, 100) })); + + if (skillsPayload.length === 0) { + setAiSearchLoading(false); + return; + } + + const res = await fetch('/api/skills/search', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + signal: abortController.signal, + body: JSON.stringify({ + query: popoverFilter, + skills: skillsPayload, + model: modelName || 'haiku', + }), + }); + + if (abortController.signal.aborted) return; + + if (!res.ok) { + setAiSuggestions([]); + setAiSearchLoading(false); + return; + } + + const data = await res.json(); + const suggestions: string[] = data.suggestions || []; + + // Map suggested names back to PopoverItems, deduplicating against substring results + const filteredNames = new Set(filteredItems.map(i => i.label)); + const aiItems = suggestions + .filter(name => !filteredNames.has(name)) + .map(name => popoverItems.find(i => i.label === name)) + .filter((item): item is PopoverItem => !!item); + + setAiSuggestions(aiItems); + } catch { + // Silently fail — don't show AI suggestions on error + if (!abortController.signal.aborted) { + setAiSuggestions([]); + } + } finally { + if (!abortController.signal.aborted) { + setAiSearchLoading(false); + } + } + }, 500); + + return () => { + if (aiSearchTimerRef.current) { + clearTimeout(aiSearchTimerRef.current); + aiSearchTimerRef.current = null; + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [popoverFilter, popoverMode, nonBuiltInFilteredCount]); + + // Combined list for keyboard navigation + const allDisplayedItems = [...filteredItems, ...aiSuggestions]; + const handleKeyDown = useCallback( (e: KeyboardEvent) => { // Popover navigation if (popoverMode && popoverItems.length > 0) { if (e.key === 'ArrowDown') { e.preventDefault(); - setSelectedIndex((prev) => (prev + 1) % filteredItems.length); + setSelectedIndex((prev) => (prev + 1) % allDisplayedItems.length); return; } if (e.key === 'ArrowUp') { e.preventDefault(); - setSelectedIndex((prev) => (prev - 1 + filteredItems.length) % filteredItems.length); + setSelectedIndex((prev) => (prev - 1 + allDisplayedItems.length) % allDisplayedItems.length); return; } if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); - if (filteredItems[selectedIndex]) { - insertItem(filteredItems[selectedIndex]); + if (allDisplayedItems[selectedIndex]) { + insertItem(allDisplayedItems[selectedIndex]); } return; } @@ -686,7 +804,7 @@ export function MessageInput({ return; } }, - [popoverMode, popoverItems, popoverFilter, selectedIndex, insertItem, closePopover, badge, inputValue, removeBadge] + [popoverMode, popoverItems, popoverFilter, selectedIndex, insertItem, closePopover, badge, inputValue, removeBadge, allDisplayedItems] ); // Click outside to close popover @@ -713,10 +831,6 @@ export function MessageInput({ return () => document.removeEventListener('mousedown', handler); }, [modelMenuOpen]); - const filteredItems = popoverItems.filter((item) => - item.label.toLowerCase().includes(popoverFilter.toLowerCase()) - ); - const currentModelValue = modelName || 'sonnet'; const currentModelOption = MODEL_OPTIONS.find((m) => m.value === currentModelValue) || MODEL_OPTIONS[0]; @@ -728,7 +842,7 @@ export function MessageInput({
{/* Popover */} - {popoverMode && filteredItems.length > 0 && (() => { + {popoverMode && (allDisplayedItems.length > 0 || aiSearchLoading) && (() => { const builtInItems = filteredItems.filter(item => item.builtIn); const projectItems = filteredItems.filter(item => !item.builtIn && item.source === 'project'); const skillItems = filteredItems.filter(item => !item.builtIn && item.source !== 'project'); @@ -795,14 +909,14 @@ export function MessageInput({ onKeyDown={(e) => { if (e.key === 'ArrowDown') { e.preventDefault(); - setSelectedIndex((prev) => (prev + 1) % filteredItems.length); + setSelectedIndex((prev) => (prev + 1) % allDisplayedItems.length); } else if (e.key === 'ArrowUp') { e.preventDefault(); - setSelectedIndex((prev) => (prev - 1 + filteredItems.length) % filteredItems.length); + setSelectedIndex((prev) => (prev - 1 + allDisplayedItems.length) % allDisplayedItems.length); } else if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); - if (filteredItems[selectedIndex]) { - insertItem(filteredItems[selectedIndex]); + if (allDisplayedItems[selectedIndex]) { + insertItem(allDisplayedItems[selectedIndex]); } } else if (e.key === 'Escape') { e.preventDefault(); @@ -857,6 +971,22 @@ export function MessageInput({ })} )} + {/* AI Suggested section */} + {(aiSuggestions.length > 0 || aiSearchLoading) && ( + <> +
+ + {t('messageInput.aiSuggested')} + {aiSearchLoading && ( + + )} +
+ {aiSuggestions.map((item) => { + const idx = globalIdx++; + return renderItem(item, idx); + })} + + )} )}
diff --git a/src/i18n/en.ts b/src/i18n/en.ts index a994ff0..e9f6d9d 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -44,6 +44,7 @@ const en = { 'messageInput.memoryDesc': 'Edit project memory file', 'messageInput.modeCode': 'Code', 'messageInput.modePlan': 'Plan', + 'messageInput.aiSuggested': 'AI Suggested', // ── Streaming message ─────────────────────────────────────── 'streaming.thinking': 'Thinking...', diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 094fcd2..5149c30 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -41,6 +41,7 @@ const zh: Record = { 'messageInput.memoryDesc': '编辑项目记忆文件', 'messageInput.modeCode': '代码', 'messageInput.modePlan': '计划', + 'messageInput.aiSuggested': 'AI 推荐', // ── Streaming message ─────────────────────────────────────── 'streaming.thinking': '思考中...',