feat: add AI semantic search for skill matching in / command popover
- 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 <noreply@anthropic.com>
This commit is contained in:
1 parent
52f5bb5f86
commit
fa1023c483
4 files changed
+319
-14
No files matched your search
@@ -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<string, string> = {
|
||||
sonnet: 'claude-sonnet-4-20250514',
|
||||
opus: 'claude-opus-4-20250514',
|
||||
haiku: 'claude-haiku-4-20250414',
|
||||
};
|
||||
|
||||
interface ApiConfig {
|
||||
supported: boolean;
|
||||
url?: string;
|
||||
headers?: Record<string, string>;
|
||||
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<string, string> = {};
|
||||
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<string, string> = {
|
||||
'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: [] });
|
||||
}
|
||||
}
|
||||
@@ -351,6 +351,10 @@ export function MessageInput({
|
||||
const [badge, setBadge] = useState<CommandBadge | null>(null);
|
||||
const [dynamicModels, setDynamicModels] = useState<{ value: string; label: string }[]>(DEFAULT_MODEL_OPTIONS);
|
||||
const [activeProviderName, setActiveProviderName] = useState<string | null>(null);
|
||||
const [aiSuggestions, setAiSuggestions] = useState<PopoverItem[]>([]);
|
||||
const [aiSearchLoading, setAiSearchLoading] = useState(false);
|
||||
const aiSearchAbortRef = useRef<AbortController | null>(null);
|
||||
const aiSearchTimerRef = useRef<ReturnType<typeof setTimeout> | 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<HTMLTextAreaElement>) => {
|
||||
// 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({
|
||||
<div className="mx-auto">
|
||||
<div className="relative">
|
||||
{/* 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) && (
|
||||
<>
|
||||
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground flex items-center gap-1.5">
|
||||
<HugeiconsIcon icon={BrainIcon} className="h-3.5 w-3.5" />
|
||||
{t('messageInput.aiSuggested')}
|
||||
{aiSearchLoading && (
|
||||
<span className="inline-block h-3 w-3 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
)}
|
||||
</div>
|
||||
{aiSuggestions.map((item) => {
|
||||
const idx = globalIdx++;
|
||||
return renderItem(item, idx);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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...',
|
||||
|
||||
@@ -41,6 +41,7 @@ const zh: Record<TranslationKey, string> = {
|
||||
'messageInput.memoryDesc': '编辑项目记忆文件',
|
||||
'messageInput.modeCode': '代码',
|
||||
'messageInput.modePlan': '计划',
|
||||
'messageInput.aiSuggested': 'AI 推荐',
|
||||
|
||||
// ── Streaming message ───────────────────────────────────────
|
||||
'streaming.thinking': '思考中...',
|
||||
|
||||
Reference in new issue
Block a user