mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-26 19:44:51 +02:00
486 lines
18 KiB
TypeScript
486 lines
18 KiB
TypeScript
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
|
import Box from '@mui/material/Box';
|
|
import Typography from '@mui/material/Typography';
|
|
import Paper from '@mui/material/Paper';
|
|
import PsychologyIcon from '@mui/icons-material/Psychology';
|
|
import SmartToyOutlinedIcon from '@mui/icons-material/SmartToyOutlined';
|
|
import QuestionAnswerOutlinedIcon from '@mui/icons-material/QuestionAnswerOutlined';
|
|
import MapOutlinedIcon from '@mui/icons-material/MapOutlined';
|
|
import CategoryOutlinedIcon from '@mui/icons-material/CategoryOutlined';
|
|
import TuneOutlinedIcon from '@mui/icons-material/TuneOutlined';
|
|
import InsertDriveFileOutlinedIcon from '@mui/icons-material/InsertDriveFileOutlined';
|
|
import LanguageIcon from '@mui/icons-material/Language';
|
|
import BuildOutlinedIcon from '@mui/icons-material/BuildOutlined';
|
|
import SvgIcon from '@mui/material/SvgIcon';
|
|
import ViewQuiltOutlinedIcon from '@mui/icons-material/ViewQuiltOutlined';
|
|
import { useAppSelector, useAppDispatch } from '@/shared/hooks';
|
|
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
|
import { fetchBuiltinTools, fetchTools } from '@/shared/state/toolsSlice';
|
|
import { fetchSkills } from '@/shared/state/skillsSlice';
|
|
|
|
const GoogleIcon: React.FC<{ sx?: object }> = ({ sx }) => (
|
|
<SvgIcon sx={sx} viewBox="0 0 24 24">
|
|
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" fill="#4285F4" />
|
|
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853" />
|
|
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05" />
|
|
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335" />
|
|
</SvgIcon>
|
|
);
|
|
|
|
const RedditIcon: React.FC<{ sx?: object }> = ({ sx }) => (
|
|
<SvgIcon sx={sx} viewBox="0 0 24 24">
|
|
<path d="M14.238 15.348c.085.084.085.221 0 .306-.465.462-1.194.687-2.231.687l-.008-.002-.008.002c-1.036 0-1.766-.225-2.231-.688-.085-.084-.085-.221 0-.305.084-.084.222-.084.307 0 .379.377 1.008.561 1.924.561l.008.002.008-.002c.915 0 1.544-.184 1.924-.561.085-.084.223-.084.307 0zm-3.44-2.418c0-.507-.414-.919-.922-.919-.509 0-.922.412-.922.919 0 .506.414.918.922.918.508 0 .922-.412.922-.918zm4.04-.919c-.509 0-.922.412-.922.919 0 .506.414.918.922.918.508 0 .922-.412.922-.918 0-.507-.414-.919-.922-.919zM12 2C6.478 2 2 6.477 2 12c0 5.522 4.478 10 10 10s10-4.478 10-10c0-5.523-4.478-10-10-10zm5.8 11.333c.02.14.03.283.03.428 0 2.19-2.547 3.964-5.69 3.964-3.142 0-5.69-1.774-5.69-3.964 0-.145.01-.288.03-.428A1.588 1.588 0 0 1 5.6 12c0-.881.716-1.596 1.599-1.596.424 0 .808.17 1.09.443 1.07-.742 2.554-1.22 4.19-1.284l.782-3.674a.11.11 0 0 1 .13-.083l2.603.556a1.132 1.132 0 0 1 2.154.481 1.134 1.134 0 0 1-1.132 1.133 1.132 1.132 0 0 1-1.105-.896l-2.318-.495-.69 3.248c1.6.08 3.046.56 4.094 1.29.283-.278.67-.45 1.099-.45.882 0 1.599.715 1.599 1.596 0 .56-.29 1.05-.726 1.334z" />
|
|
</SvgIcon>
|
|
);
|
|
|
|
const TOOL_GROUP_ICONS: Record<string, React.FC<{ sx?: object }>> = {
|
|
Google: GoogleIcon,
|
|
Reddit: RedditIcon,
|
|
Web: LanguageIcon,
|
|
View: ViewQuiltOutlinedIcon,
|
|
};
|
|
|
|
export function getToolGroupIcon(groupName: string, size: number = 15): React.ReactNode {
|
|
const Icon = TOOL_GROUP_ICONS[groupName];
|
|
if (Icon) return <Icon sx={{ fontSize: size }} />;
|
|
return <BuildOutlinedIcon sx={{ fontSize: size }} />;
|
|
}
|
|
|
|
export interface CommandPickerItem {
|
|
id: string;
|
|
type: 'skill' | 'mode' | 'context';
|
|
category: string;
|
|
name: string;
|
|
description: string;
|
|
command: string;
|
|
icon: React.ReactNode;
|
|
toolNames?: string[];
|
|
iconKey?: string;
|
|
}
|
|
|
|
interface Props {
|
|
trigger: '/' | '@';
|
|
filter: string;
|
|
onSelect: (item: CommandPickerItem) => void;
|
|
onClose: () => void;
|
|
visible: boolean;
|
|
}
|
|
|
|
const MODE_ICON_MAP: Record<string, React.ComponentType<{ sx?: object }>> = {
|
|
smart_toy: SmartToyOutlinedIcon,
|
|
question_answer: QuestionAnswerOutlinedIcon,
|
|
map: MapOutlinedIcon,
|
|
category: CategoryOutlinedIcon,
|
|
tune: TuneOutlinedIcon,
|
|
};
|
|
|
|
function highlightMatch(text: string, query: string, color: string): React.ReactNode {
|
|
if (!query) return text;
|
|
const idx = text.toLowerCase().indexOf(query.toLowerCase());
|
|
if (idx === -1) return text;
|
|
return (
|
|
<>
|
|
{text.slice(0, idx)}
|
|
<span style={{ color, fontWeight: 600 }}>{text.slice(idx, idx + query.length)}</span>
|
|
{text.slice(idx + query.length)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
const CommandPicker: React.FC<Props> = ({ trigger, filter, onSelect, onClose, visible }) => {
|
|
const c = useClaudeTokens();
|
|
const dispatch = useAppDispatch();
|
|
const skills = useAppSelector((s) => s.skills.items);
|
|
const modesMap = useAppSelector((s) => s.modes.items);
|
|
const builtinTools = useAppSelector((s) => s.tools.builtinTools);
|
|
const customTools = useAppSelector((s) => s.tools.items);
|
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
|
|
const toolsLoaded = useAppSelector((s) => s.tools.loaded);
|
|
const builtinLoaded = useAppSelector((s) => s.tools.builtinLoaded);
|
|
const skillsLoaded = useAppSelector((s) => s.skills.loaded);
|
|
|
|
useEffect(() => {
|
|
if (!builtinLoaded) dispatch(fetchBuiltinTools());
|
|
if (!toolsLoaded) dispatch(fetchTools());
|
|
if (!skillsLoaded) dispatch(fetchSkills());
|
|
}, [dispatch, builtinLoaded, toolsLoaded, skillsLoaded]);
|
|
|
|
const items: CommandPickerItem[] = useMemo(() => {
|
|
let all: CommandPickerItem[] = [];
|
|
|
|
if (trigger === '/') {
|
|
const skillItems: CommandPickerItem[] = Object.values(skills).map((s) => ({
|
|
id: s.id,
|
|
type: 'skill' as const,
|
|
category: 'Skills',
|
|
name: s.name,
|
|
description: s.description || 'Skill',
|
|
command: s.command || s.id,
|
|
icon: <PsychologyIcon sx={{ fontSize: 15 }} />,
|
|
}));
|
|
|
|
const modeItems: CommandPickerItem[] = Object.values(modesMap).map((m) => {
|
|
const IconComp = MODE_ICON_MAP[m.icon] || SmartToyOutlinedIcon;
|
|
return {
|
|
id: m.id,
|
|
type: 'mode' as const,
|
|
category: 'Modes',
|
|
name: m.name,
|
|
description: m.description || 'Switch to this mode',
|
|
command: m.name.toLowerCase().replace(/\s+/g, '-'),
|
|
icon: <IconComp sx={{ fontSize: 15 }} />,
|
|
};
|
|
});
|
|
|
|
all = [...skillItems, ...modeItems];
|
|
} else {
|
|
const atItems: CommandPickerItem[] = [
|
|
{
|
|
id: 'file',
|
|
type: 'context' as const,
|
|
category: 'Context',
|
|
name: 'File',
|
|
description: 'Attach a file or folder as context',
|
|
command: 'file',
|
|
icon: <InsertDriveFileOutlinedIcon sx={{ fontSize: 15 }} />,
|
|
},
|
|
];
|
|
|
|
const hasWebSearch = builtinTools.some((t) => t.name === 'WebSearch' && t.deferred);
|
|
const hasWebFetch = builtinTools.some((t) => t.name === 'WebFetch' && t.deferred);
|
|
if (hasWebSearch || hasWebFetch) {
|
|
const webTools = [hasWebSearch && 'WebSearch', hasWebFetch && 'WebFetch'].filter(Boolean) as string[];
|
|
atItems.push({
|
|
id: 'web',
|
|
type: 'context' as const,
|
|
category: 'Actions',
|
|
name: 'Web',
|
|
description: 'Search the web and fetch URLs',
|
|
command: 'web',
|
|
icon: <LanguageIcon sx={{ fontSize: 15 }} />,
|
|
toolNames: webTools,
|
|
iconKey: 'Web',
|
|
});
|
|
}
|
|
|
|
for (const tool of Object.values(customTools)) {
|
|
if (!tool.mcp_config || Object.keys(tool.mcp_config).length === 0) continue;
|
|
const services = tool.tool_permissions?._services as Record<string, { read: string[]; write: string[] }> | undefined;
|
|
if (!services) continue;
|
|
const perms = tool.tool_permissions as Record<string, any>;
|
|
const serviceGroups = (tool.tool_permissions?._service_groups ?? {}) as Record<string, string[]>;
|
|
|
|
const enabledServices: { name: string; tools: string[] }[] = [];
|
|
for (const [serviceName, serviceTools] of Object.entries(services)) {
|
|
const allToolNames = [...(serviceTools.read || []), ...(serviceTools.write || [])];
|
|
const enabled = allToolNames.filter((name) => perms[name] !== 'deny');
|
|
if (enabled.length > 0) enabledServices.push({ name: serviceName, tools: enabled });
|
|
}
|
|
|
|
if (enabledServices.length === 0) continue;
|
|
|
|
const groupEntries = Object.entries(serviceGroups);
|
|
const emittedServices = new Set<string>();
|
|
|
|
for (const [groupName, groupServiceNames] of groupEntries) {
|
|
const groupCmd = groupName.toLowerCase().replace(/\s+/g, '-');
|
|
const groupServices = enabledServices.filter((s) => groupServiceNames.includes(s.name));
|
|
if (groupServices.length === 0) continue;
|
|
groupServices.forEach((s) => emittedServices.add(s.name));
|
|
|
|
const groupIcon = getToolGroupIcon(groupName);
|
|
if (groupServices.length >= 2) {
|
|
const allTools = groupServices.flatMap((s) => s.tools);
|
|
atItems.push({
|
|
id: `mcp-${tool.id}-group-${groupName}`,
|
|
type: 'context' as const,
|
|
category: tool.name,
|
|
name: groupName,
|
|
description: `Use all ${groupName} actions`,
|
|
command: groupCmd,
|
|
icon: groupIcon,
|
|
toolNames: allTools,
|
|
iconKey: groupName,
|
|
});
|
|
for (const svc of groupServices) {
|
|
atItems.push({
|
|
id: `mcp-${tool.id}-${svc.name}`,
|
|
type: 'context' as const,
|
|
category: tool.name,
|
|
name: svc.name,
|
|
description: `Use ${svc.name} actions from ${tool.name}`,
|
|
command: `${groupCmd}/${svc.name.toLowerCase().replace(/\s+/g, '-')}`,
|
|
icon: groupIcon,
|
|
toolNames: svc.tools,
|
|
iconKey: groupName,
|
|
});
|
|
}
|
|
} else {
|
|
const svc = groupServices[0];
|
|
atItems.push({
|
|
id: `mcp-${tool.id}-${svc.name}`,
|
|
type: 'context' as const,
|
|
category: tool.name,
|
|
name: svc.name,
|
|
description: `Use ${svc.name} actions from ${tool.name}`,
|
|
command: svc.name.toLowerCase().replace(/\s+/g, '-'),
|
|
icon: groupIcon,
|
|
toolNames: svc.tools,
|
|
iconKey: groupName,
|
|
});
|
|
}
|
|
}
|
|
|
|
for (const svc of enabledServices) {
|
|
if (emittedServices.has(svc.name)) continue;
|
|
atItems.push({
|
|
id: `mcp-${tool.id}-${svc.name}`,
|
|
type: 'context' as const,
|
|
category: tool.name,
|
|
name: svc.name,
|
|
description: `Use ${svc.name} actions from ${tool.name}`,
|
|
command: svc.name.toLowerCase().replace(/\s+/g, '-'),
|
|
icon: <BuildOutlinedIcon sx={{ fontSize: 15 }} />,
|
|
toolNames: svc.tools,
|
|
});
|
|
}
|
|
}
|
|
|
|
all = atItems;
|
|
}
|
|
|
|
if (!filter) return all;
|
|
const lower = filter.toLowerCase();
|
|
return all.filter(
|
|
(item) =>
|
|
item.name.toLowerCase().includes(lower) ||
|
|
item.command.toLowerCase().includes(lower) ||
|
|
item.description.toLowerCase().includes(lower),
|
|
);
|
|
}, [trigger, skills, modesMap, builtinTools, customTools, filter]);
|
|
|
|
const flatItems = useMemo(() => {
|
|
const result: { item: CommandPickerItem; isGroupStart: boolean; category: string }[] = [];
|
|
let lastCat = '';
|
|
for (const item of items) {
|
|
result.push({ item, isGroupStart: item.category !== lastCat, category: item.category });
|
|
lastCat = item.category;
|
|
}
|
|
return result;
|
|
}, [items]);
|
|
|
|
const getIconColor = (item: CommandPickerItem): string => {
|
|
switch (item.type) {
|
|
case 'skill': return c.status.success;
|
|
case 'mode': {
|
|
const mode = modesMap[item.id];
|
|
return mode?.color || c.accent.primary;
|
|
}
|
|
case 'context': return c.text.tertiary;
|
|
default: return c.text.tertiary;
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
setSelectedIndex(0);
|
|
}, [filter, trigger]);
|
|
|
|
useEffect(() => {
|
|
if (!containerRef.current) return;
|
|
const el = containerRef.current.querySelector(`[data-picker-idx="${selectedIndex}"]`);
|
|
if (el) el.scrollIntoView({ block: 'nearest' });
|
|
}, [selectedIndex]);
|
|
|
|
useEffect(() => {
|
|
if (!visible) return;
|
|
const handler = (e: KeyboardEvent) => {
|
|
switch (e.key) {
|
|
case 'ArrowDown':
|
|
e.preventDefault();
|
|
setSelectedIndex((p) => (p < items.length - 1 ? p + 1 : p));
|
|
break;
|
|
case 'ArrowUp':
|
|
e.preventDefault();
|
|
setSelectedIndex((p) => (p > 0 ? p - 1 : p));
|
|
break;
|
|
case 'Enter':
|
|
case 'Tab':
|
|
if (items[selectedIndex]) {
|
|
e.preventDefault();
|
|
onSelect(items[selectedIndex]);
|
|
}
|
|
break;
|
|
case 'Escape':
|
|
e.preventDefault();
|
|
onClose();
|
|
break;
|
|
}
|
|
};
|
|
window.addEventListener('keydown', handler);
|
|
return () => window.removeEventListener('keydown', handler);
|
|
}, [visible, items, selectedIndex, onSelect, onClose]);
|
|
|
|
if (!visible || items.length === 0) return null;
|
|
|
|
return (
|
|
<Paper
|
|
ref={containerRef}
|
|
elevation={0}
|
|
sx={{
|
|
position: 'absolute',
|
|
bottom: '100%',
|
|
left: 0,
|
|
right: 0,
|
|
mb: 0.5,
|
|
bgcolor: c.bg.surface,
|
|
border: `1px solid ${c.border.subtle}`,
|
|
borderRadius: '12px',
|
|
maxHeight: 320,
|
|
overflow: 'auto',
|
|
zIndex: 1000,
|
|
boxShadow: c.shadow.lg,
|
|
animation: 'cmdPickerIn 120ms ease-out',
|
|
'@keyframes cmdPickerIn': {
|
|
from: { opacity: 0, transform: 'translateY(4px)' },
|
|
to: { opacity: 1, transform: 'translateY(0)' },
|
|
},
|
|
'&::-webkit-scrollbar': { width: 4 },
|
|
'&::-webkit-scrollbar-track': { background: 'transparent' },
|
|
'&::-webkit-scrollbar-thumb': {
|
|
background: c.border.medium,
|
|
borderRadius: 2,
|
|
'&:hover': { background: c.border.strong },
|
|
},
|
|
scrollbarWidth: 'thin',
|
|
scrollbarColor: `${c.border.medium} transparent`,
|
|
}}
|
|
>
|
|
<Box sx={{ py: 0.5 }}>
|
|
{flatItems.map(({ item, isGroupStart, category }, idx) => (
|
|
<React.Fragment key={`${item.type}-${item.id}`}>
|
|
{isGroupStart && (
|
|
<Box sx={{ px: 1.5, pt: idx === 0 ? 0.75 : 1.25, pb: 0.375 }}>
|
|
<Typography
|
|
sx={{
|
|
color: c.text.ghost,
|
|
fontSize: '0.625rem',
|
|
fontWeight: 700,
|
|
textTransform: 'uppercase',
|
|
letterSpacing: '0.08em',
|
|
}}
|
|
>
|
|
{category}
|
|
</Typography>
|
|
</Box>
|
|
)}
|
|
<Box
|
|
data-picker-idx={idx}
|
|
onClick={() => onSelect(item)}
|
|
onMouseEnter={() => setSelectedIndex(idx)}
|
|
sx={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 1,
|
|
px: 1.25,
|
|
py: 0.5,
|
|
mx: 0.5,
|
|
borderRadius: '8px',
|
|
cursor: 'pointer',
|
|
bgcolor: idx === selectedIndex ? `${c.accent.primary}0a` : 'transparent',
|
|
'&:hover': { bgcolor: `${c.accent.primary}0a` },
|
|
transition: 'background-color 60ms ease',
|
|
}}
|
|
>
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
width: 24,
|
|
height: 24,
|
|
flexShrink: 0,
|
|
borderRadius: '6px',
|
|
bgcolor: `${getIconColor(item)}12`,
|
|
color: getIconColor(item),
|
|
}}
|
|
>
|
|
{item.icon}
|
|
</Box>
|
|
<Typography
|
|
component="span"
|
|
sx={{
|
|
color: c.text.primary,
|
|
fontSize: '0.8rem',
|
|
fontWeight: 500,
|
|
fontFamily: c.font.mono,
|
|
whiteSpace: 'nowrap',
|
|
lineHeight: 1.3,
|
|
}}
|
|
>
|
|
{trigger}{highlightMatch(item.command, filter, c.accent.primary)}
|
|
</Typography>
|
|
<Typography
|
|
component="span"
|
|
sx={{
|
|
color: c.text.muted,
|
|
fontSize: '0.72rem',
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
whiteSpace: 'nowrap',
|
|
flex: 1,
|
|
ml: 0.5,
|
|
lineHeight: 1.3,
|
|
}}
|
|
>
|
|
{item.description}
|
|
</Typography>
|
|
</Box>
|
|
</React.Fragment>
|
|
))}
|
|
</Box>
|
|
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 1.5,
|
|
px: 1.5,
|
|
py: 0.625,
|
|
borderTop: `1px solid ${c.border.subtle}`,
|
|
}}
|
|
>
|
|
{[
|
|
{ keys: '↑↓', label: 'navigate' },
|
|
{ keys: '↵', label: 'select' },
|
|
{ keys: 'esc', label: 'dismiss' },
|
|
].map(({ keys, label }) => (
|
|
<Box key={label} sx={{ display: 'flex', alignItems: 'center', gap: 0.375 }}>
|
|
<Typography
|
|
sx={{
|
|
fontSize: '0.58rem',
|
|
fontFamily: c.font.mono,
|
|
color: c.text.ghost,
|
|
bgcolor: c.bg.secondary,
|
|
px: 0.5,
|
|
py: 0.125,
|
|
borderRadius: '3px',
|
|
border: `1px solid ${c.border.subtle}`,
|
|
lineHeight: 1.3,
|
|
}}
|
|
>
|
|
{keys}
|
|
</Typography>
|
|
<Typography sx={{ fontSize: '0.58rem', color: c.text.ghost }}>
|
|
{label}
|
|
</Typography>
|
|
</Box>
|
|
))}
|
|
</Box>
|
|
</Paper>
|
|
);
|
|
};
|
|
|
|
export default CommandPicker;
|