feat: support CMD K

This commit is contained in:
René Wang
2025-12-20 22:48:58 +08:00
committed by arvinxx
parent 685a6cd5a5
commit d2bd8a6d84
16 changed files with 2565 additions and 3 deletions
+39
View File
@@ -0,0 +1,39 @@
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import type { ChatMessage } from './types';
interface ChatListProps {
messages: ChatMessage[];
styles: {
chatContainer: string;
chatMessage: string;
chatMessageContent: string;
chatMessageRole: string;
};
}
const ChatList = memo<ChatListProps>(({ messages, styles }) => {
const { t } = useTranslation('common');
return (
<div className={styles.chatContainer}>
{messages.length === 0 ? (
<div style={{ opacity: 0.5, padding: '16px', textAlign: 'center' }}>
{t('cmdk.aiModeEmptyState')}
</div>
) : (
messages.map((message) => (
<div className={styles.chatMessage} key={message.id}>
<div className={styles.chatMessageRole}>{message.role}</div>
<div className={styles.chatMessageContent}>{message.content}</div>
</div>
))
)}
</div>
);
});
ChatList.displayName = 'ChatList';
export default ChatList;
@@ -0,0 +1,99 @@
import { Command } from 'cmdk';
import { ChevronRight } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import type { Context } from './types';
import { getContextCommands } from './utils/contextCommands';
interface ContextCommandsProps {
context: Context;
onNavigate: (path: string) => void;
styles: {
icon: string;
itemContent: string;
itemLabel: string;
};
}
const ContextCommands = memo<ContextCommandsProps>(({ context, onNavigate, styles }) => {
const { t } = useTranslation('setting');
const { t: tAuth } = useTranslation('auth');
const { t: tCommon } = useTranslation('common');
const { t: tChat } = useTranslation('chat');
const commands = getContextCommands(context.type, context.subPath);
if (commands.length === 0) return null;
// Get localized context name
const getContextName = () => {
switch (context.type) {
case 'settings': {
return t('header.title', { defaultValue: context.name });
}
case 'agent': {
return tCommon('cmdk.search.agent', { defaultValue: context.name });
}
case 'group': {
return tChat('group.title', { defaultValue: context.name });
}
case 'page': {
return tCommon('cmdk.pages', { defaultValue: context.name });
}
case 'painting': {
return tCommon('cmdk.painting', { defaultValue: context.name });
}
case 'resource': {
return tCommon('cmdk.resource', { defaultValue: context.name });
}
default: {
return context.name;
}
}
};
const contextName = getContextName();
return (
<Command.Group>
{commands.map((cmd) => {
const Icon = cmd.icon;
// Get localized label using the correct namespace
let label = cmd.label;
if (cmd.labelKey) {
if (cmd.labelNamespace === 'auth') {
label = tAuth(cmd.labelKey, { defaultValue: cmd.label });
} else {
label = t(cmd.labelKey, { defaultValue: cmd.label });
}
}
const searchValue = `${contextName} ${label} ${cmd.keywords.join(' ')}`;
return (
<Command.Item key={cmd.path} onSelect={() => onNavigate(cmd.path)} value={searchValue}>
<Icon className={styles.icon} />
<div className={styles.itemContent}>
<div className={styles.itemLabel}>
<span style={{ opacity: 0.5 }}>{contextName}</span>
<ChevronRight
size={14}
style={{
display: 'inline',
marginInline: '6px',
opacity: 0.5,
verticalAlign: 'middle',
}}
/>
{label}
</div>
</div>
</Command.Item>
);
})}
</Command.Group>
);
});
ContextCommands.displayName = 'ContextCommands';
export default ContextCommands;
+158
View File
@@ -0,0 +1,158 @@
import { DiscordIcon } from '@lobehub/ui/icons';
import { Command } from 'cmdk';
import {
Bot,
BrainCircuit,
FilePen,
Github,
Image,
LibraryBig,
Monitor,
Settings,
Shapes,
Star,
} from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import ContextCommands from './ContextCommands';
import type { Context } from './types';
interface MainMenuProps {
context?: Context;
onCreateSession: () => void;
onExternalLink: (url: string) => void;
onNavigate: (path: string) => void;
onNavigateToTheme: () => void;
pathname: string | null;
showCreateSession?: boolean;
styles: {
icon: string;
itemContent: string;
itemLabel: string;
};
}
const MainMenu = memo<MainMenuProps>(
({
context,
onCreateSession,
onExternalLink,
onNavigate,
onNavigateToTheme,
pathname,
styles,
}) => {
const { t } = useTranslation('common');
return (
<>
{context && <ContextCommands context={context} onNavigate={onNavigate} styles={styles} />}
<Command.Group>
<Command.Item onSelect={onCreateSession} value="create new agent assistant">
<Bot className={styles.icon} />
<div className={styles.itemContent}>
<div className={styles.itemLabel}>{t('cmdk.newAgent')}</div>
</div>
</Command.Item>
{!pathname?.startsWith('/settings') && (
<Command.Item onSelect={() => onNavigate('/settings')} value="settings">
<Settings className={styles.icon} />
<div className={styles.itemContent}>
<div className={styles.itemLabel}>{t('cmdk.settings')}</div>
</div>
</Command.Item>
)}
<Command.Item onSelect={onNavigateToTheme} value="theme">
<Monitor className={styles.icon} />
<div className={styles.itemContent}>
<div className={styles.itemLabel}>{t('cmdk.theme')}</div>
</div>
</Command.Item>
</Command.Group>
<Command.Group heading={t('cmdk.navigate')}>
{!pathname?.startsWith('/community') && (
<Command.Item onSelect={() => onNavigate('/community')} value="community">
<Shapes className={styles.icon} />
<div className={styles.itemContent}>
<div className={styles.itemLabel}>{t('cmdk.community')}</div>
</div>
</Command.Item>
)}
{!pathname?.startsWith('/image') && (
<Command.Item onSelect={() => onNavigate('/image')} value="painting">
<Image className={styles.icon} />
<div className={styles.itemContent}>
<div className={styles.itemLabel}>{t('cmdk.painting')}</div>
</div>
</Command.Item>
)}
{!pathname?.startsWith('/knowledge') && (
<Command.Item onSelect={() => onNavigate('/resource')} value="resource">
<LibraryBig className={styles.icon} />
<div className={styles.itemContent}>
<div className={styles.itemLabel}>{t('cmdk.resource')}</div>
</div>
</Command.Item>
)}
{!pathname?.startsWith('/page') && (
<Command.Item onSelect={() => onNavigate('/page')} value="page documents write">
<FilePen className={styles.icon} />
<div className={styles.itemContent}>
<div className={styles.itemLabel}>{t('cmdk.pages')}</div>
</div>
</Command.Item>
)}
{!pathname?.startsWith('/memory') && (
<Command.Item onSelect={() => onNavigate('/memory')} value="memory">
<BrainCircuit className={styles.icon} />
<div className={styles.itemContent}>
<div className={styles.itemLabel}>{t('cmdk.memory')}</div>
</div>
</Command.Item>
)}
</Command.Group>
<Command.Group heading={t('cmdk.about')}>
<Command.Item
onSelect={() =>
onExternalLink('https://github.com/lobehub/lobe-chat/issues/new/choose')
}
value="submit-issue"
>
<Github className={styles.icon} />
<div className={styles.itemContent}>
<div className={styles.itemLabel}>{t('cmdk.submitIssue')}</div>
</div>
</Command.Item>
<Command.Item
onSelect={() => onExternalLink('https://github.com/lobehub/lobe-chat')}
value="star-github"
>
<Star className={styles.icon} />
<div className={styles.itemContent}>
<div className={styles.itemLabel}>{t('cmdk.starOnGitHub')}</div>
</div>
</Command.Item>
<Command.Item
onSelect={() => onExternalLink('https://discord.gg/AYFPHvv2jT')}
value="discord"
>
<DiscordIcon className={styles.icon} />
<div className={styles.itemContent}>
<div className={styles.itemLabel}>{t('cmdk.communitySupport')}</div>
</div>
</Command.Item>
</Command.Group>
</>
);
},
);
MainMenu.displayName = 'MainMenu';
export default MainMenu;
+647
View File
@@ -0,0 +1,647 @@
# CommandMenu Feature Documentation
## Overview
The CommandMenu is a powerful command palette feature inspired by tools like Raycast and VS Code's Command Palette. It provides a unified, keyboard-driven interface for quick navigation, searching, and actions across the entire application.
**Key Library**: Built on top of [`cmdk`](https://github.com/pacocoursey/cmdk) (Command K) by Paco Coursey.
## Architecture
### Core Design Principles
1. **Context-Aware**: Automatically detects the current page/context and shows relevant commands
2. **Modal Navigation**: Uses a page stack system for hierarchical navigation (e.g., Main → Theme)
3. **Multi-Mode**: Supports different modes (default, search, AI chat)
4. **Global State**: Integrated with Zustand store for open/close state management
5. **Portal-Based**: Renders as a portal to `document.body` for proper z-index layering
### File Structure
```plaintext
CommandMenu/
├── index.tsx # Main component & orchestration
├── useCommandMenu.ts # Core hook with business logic
├── types.ts # TypeScript type definitions
├── styles.ts # antd-style CSS-in-JS styles
├── components/
│ ├── CommandInput.tsx # Search input with context/back navigation
│ └── CommandFooter.tsx # Keyboard shortcuts help
├── MainMenu.tsx # Default menu (navigation, settings, etc.)
├── ContextCommands.tsx # Context-specific commands
├── SearchResults.tsx # Search result display
├── ChatList.tsx # AI chat mode message list
├── ThemeMenu.tsx # Theme selection submenu
└── utils/
├── context.ts # Context detection logic
└── contextCommands.ts # Context command definitions
```
## Core Concepts
### 1. Context Detection
The CommandMenu automatically detects what page you're on and shows relevant commands.
**File**: `utils/context.ts`
```typescript
// Supported contexts
type ContextType = 'agent' | 'painting' | 'settings' | 'resource' | 'page';
// Context detection based on pathname
const CONTEXT_CONFIGS: ContextConfig[] = [
{ matcher: /^\/agent\/[^/]+$/, name: 'Agent', type: 'agent' },
{ matcher: /^\/image$/, name: 'Painting', type: 'painting' },
{
matcher: /^\/settings(?:\/([^/]+))?/,
name: 'Settings',
type: 'settings',
captureSubPath: true // Captures sub-route like "profile"
},
// ...
];
```
**Example**: When on `/settings/profile`, context is:
```typescript
{
type: 'settings',
name: 'Settings',
subPath: 'profile'
}
```
### 2. Page Stack Navigation
Uses an array-based stack for hierarchical navigation:
```typescript
// State
const [pages, setPages] = useState<string[]>([]);
const page = pages.at(-1); // Current page
// Navigate to submenu
navigateToPage('theme'); // pages = ['theme']
// Navigate deeper
navigateToPage('ai-chat'); // pages = ['theme', 'ai-chat']
// Go back
handleBack(); // pages = ['theme']
```
**Keyboard Shortcuts**:
- `Escape`: Go back one level or close if at root
- `Backspace`: Go back when search is empty
### 3. AI Mode
Special mode for asking AI questions about your work.
**Activation**: Press `Tab` when you have search text (and not already in AI mode)
**Flow**:
1. User types query in search
2. Presses `Tab`
3. Query becomes a user message in chat
4. Page stack pushes `'ai-chat'`
5. Search input switches to AI mode placeholder
**State Management**:
```typescript
const [chatMessages, setChatMessages] = useState<ChatMessage[]>([]);
const isAiMode = page === 'ai-chat';
const handleAskAI = () => {
if (search.trim()) {
const userMessage = {
content: search,
id: Date.now().toString(),
role: 'user',
};
setChatMessages(prev => [...prev, userMessage]);
}
setPages([...pages, 'ai-chat']);
};
```
### 4. Search Functionality
**Backend**: Uses tRPC Lambda client to query the search API
**File**: `useCommandMenu.ts:38-51`
```typescript
// Debounced search to reduce API calls
const debouncedSearch = useDebounce(search, { wait: 300 });
// SWR-based search
const { data: searchResults, isLoading: isSearching } = useSWR<SearchResult[]>(
hasSearch && !isAiMode ? ['search', searchQuery] : null,
async () => lambdaClient.search.query.query({ query: searchQuery }),
{ revalidateOnFocus: false, revalidateOnReconnect: false }
);
```
**Search Types**:
- `message`: Chat messages (NEW - highest priority in agent context)
- `agent`: AI agents/assistants
- `topic`: Conversation topics/threads
- `file`: Uploaded files/knowledge base
**Display**: Results are grouped by type in `SearchResults.tsx` with priority order: Messages → Topics → Agents → Files
**Context-Aware Priority** (NEW):
- **Agent Context** (`/agent/*`): Messages (10), Topics (5), Agents (3), Files (3)
- Messages from current agent get 0.5-0.7 relevance (highest priority)
- Other messages get 1-3 relevance (normal)
- **General Context**: Balanced 5/5/5/5 distribution
### 5. Context Commands
Commands that appear based on current context (e.g., Settings submenu navigation).
**File**: `utils/contextCommands.ts`
```typescript
// Define commands for each context type
export const CONTEXT_COMMANDS: Record<ContextType, ContextCommand[]> = {
settings: [
{
label: 'Profile',
path: '/settings/profile',
subPath: 'profile',
icon: UserCircle,
keywords: ['profile', 'user', 'account'],
},
// ...more settings pages
],
agent: [], // No context commands for agent pages yet
// ...
};
```
**Smart Filtering**: Automatically hides the current page from the list
```typescript
// If on /settings/profile, won't show "Profile" in context commands
return commands.filter((cmd) => cmd.subPath !== currentSubPath);
```
## Data Flow
### Opening the Command Menu
```
User presses Cmd+K
GlobalStore.updateSystemStatus({ showCommandMenu: true })
useCommandMenu hook detects open=true
useEffect resets state (pages=[], search='', chatMessages=[])
CommandMenu renders via portal to document.body
detectContext(pathname) determines current context
Renders appropriate menu based on state:
- No page + no search → MainMenu + ContextCommands
- No page + has search → MainMenu + SearchResults
- page='theme' → ThemeMenu
- page='ai-chat' → ChatList
```
### Search Flow
```
User types in input
setSearch(value)
useDebounce delays by 300ms
SWR key changes to ['search', query]
lambdaClient.search.query.query({ query })
SearchResults receives results
Groups by type (agents/topics/files)
Renders with type-specific icons and navigation
```
### Navigation Flow
```
User selects "Settings" command
handleNavigate('/settings') called
react-router navigate('/settings')
closeCommandMenu() → setOpen({ showCommandMenu: false })
CommandMenu unmounts
```
## Key Features
### Keyboard Shortcuts
| Key | Action |
|-----|--------|
| `Cmd/Ctrl + K` | Open/Close command menu (global) |
| `Escape` | Go back or close |
| `Backspace` | Go back (when search empty) |
| `Tab` | Enter AI mode (when search has text) |
| `↑/↓` | Navigate items |
| `Enter` | Select item |
### Smart Filtering
The `cmdk` library provides built-in fuzzy filtering:
- Searches across command labels, keywords, and descriptions
- Only active when `shouldFilter={!isAiMode}` (disabled in AI mode)
- Custom value strings for better search relevance (see `SearchResults.tsx:71-78`)
### Body Scroll Lock
```typescript
useEffect(() => {
if (open) {
const originalStyle = window.getComputedStyle(document.body).overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = originalStyle;
};
}
}, [open]);
```
### Loading States
Search results show skeleton loaders while fetching:
```typescript
if (isLoading) {
return (
<Command.Group heading={t('cmdk.search.searching')}>
{[1, 2, 3].map((i) => (
<div className={styles.skeletonItem} key={i}>
<div className={styles.skeleton} />
{/* ... */}
</div>
))}
</Command.Group>
);
}
```
## Styling
Uses `antd-style` for theme-aware CSS-in-JS:
**Key Patterns**:
1. Uses `token.*` for colors/spacing to support dark mode
2. CSS animations for smooth transitions
3. Responsive sizing with viewport units
4. Flexbox for layouts
**Example**:
```typescript
commandRoot: css`
width: min(640px, 90vw);
max-height: min(500px, 70vh);
background: ${token.colorBgElevated};
box-shadow: ${token.boxShadowSecondary};
animation: slide-down 0.12s ease-out;
@keyframes slide-down {
from {
transform: translateY(-20px) scale(0.96);
opacity: 0;
}
to {
transform: translateY(0) scale(1);
opacity: 1;
}
}
`,
```
## Integration Points
### Global State (Zustand)
```typescript
// src/store/global
interface SystemStatus {
showCommandMenu: boolean;
// ...
}
// Usage in hook
const [open, setOpen] = useGlobalStore((s) => [
s.status.showCommandMenu,
s.updateSystemStatus
]);
```
### Router Integration
```typescript
import { useLocation, useNavigate } from 'react-router-dom';
const navigate = useNavigate();
const location = useLocation();
const pathname = location.pathname;
const handleNavigate = (path: string) => {
navigate(path);
closeCommandMenu();
};
```
### tRPC Search API
```typescript
import { lambdaClient } from '@/libs/trpc/client';
// Call server-side search function
lambdaClient.search.query.query({ query: searchQuery });
```
### i18n (Internationalization)
```typescript
import { useTranslation } from 'react-i18next';
const { t } = useTranslation('common');
// Usage
<Command.Empty>{t('cmdk.noResults')}</Command.Empty>
```
**Translation Keys** (in `src/locales/default/common.ts`):
- `cmdk.searchPlaceholder`
- `cmdk.aiModePlaceholder`
- `cmdk.noResults`
- `cmdk.newAgent`
- `cmdk.settings`
- etc.
## How to Extend
### 1. Add a New Context
**Step 1**: Add context type to `types.ts`:
```typescript
export type ContextType =
| 'agent'
| 'painting'
| 'settings'
| 'resource'
| 'page'
| 'your-new-context'; // Add this
```
**Step 2**: Add detection rule to `utils/context.ts`:
```typescript
const CONTEXT_CONFIGS: ContextConfig[] = [
// ...existing configs
{
matcher: /^\/your-route/,
name: 'Your Context Name',
type: 'your-new-context',
captureSubPath: true, // optional
},
];
```
**Step 3**: Add commands to `utils/contextCommands.ts`:
```typescript
export const CONTEXT_COMMANDS: Record<ContextType, ContextCommand[]> = {
// ...
'your-new-context': [
{
label: 'Sub Command',
path: '/your-route/sub',
subPath: 'sub',
icon: YourIcon,
keywords: ['keyword1', 'keyword2'],
},
],
};
```
### 2. Add a New Menu Page
**Step 1**: Create component (e.g., `YourMenu.tsx`):
```typescript
import { Command } from 'cmdk';
import { memo } from 'react';
interface YourMenuProps {
onSomething: (value: string) => void;
styles: any;
}
const YourMenu = memo<YourMenuProps>(({ onSomething, styles }) => {
return (
<>
<Command.Item onSelect={() => onSomething('value1')} value="option-1">
<YourIcon className={styles.icon} />
<div className={styles.itemContent}>
<div className={styles.itemLabel}>Option 1</div>
</div>
</Command.Item>
{/* More items... */}
</>
);
});
export default YourMenu;
```
**Step 2**: Add handler to `useCommandMenu.ts`:
```typescript
const handleYourAction = (value: string) => {
// Do something
closeCommandMenu();
};
return {
// ...
handleYourAction,
};
```
**Step 3**: Render in `index.tsx`:
```typescript
{page === 'your-page' && (
<YourMenu
onSomething={handleYourAction}
styles={styles}
/>
)}
```
**Step 4**: Add navigation to it:
```typescript
// In MainMenu or elsewhere
<Command.Item onSelect={() => navigateToPage('your-page')}>
Your Page
</Command.Item>
```
### 3. Add a New Main Menu Item
In `MainMenu.tsx`:
```typescript
<Command.Item
onSelect={() => onNavigate('/your-route')}
value="your-command keywords here"
>
<YourIcon className={styles.icon} />
<div className={styles.itemContent}>
<div className={styles.itemLabel}>{t('cmdk.yourLabel')}</div>
</div>
</Command.Item>
```
Remember to:
1. Add translation keys to `src/locales/default/common.ts`
2. Use existing icons from `lucide-react`
3. Follow the existing pattern for consistency
### 4. Modify Search Behavior
Search is handled server-side via tRPC. To modify:
**Backend**: Update `src/server/routers/lambda/search.ts` (or similar)
**Frontend**: Search results display in `SearchResults.tsx`
- Modify `getIcon()` for custom icons
- Modify `handleNavigate()` for custom routing
- Modify `getItemValue()` for search ranking
## Common Patterns
### Command Item Structure
```typescript
<Command.Item
onSelect={() => handleAction()}
value="searchable keywords here"
>
<Icon className={styles.icon} />
<div className={styles.itemContent}>
<div className={styles.itemLabel}>Primary Label</div>
</div>
</Command.Item>
```
### Grouped Commands
```typescript
<Command.Group heading={t('cmdk.groupName')}>
<Command.Item>...</Command.Item>
<Command.Item>...</Command.Item>
</Command.Group>
```
### Conditional Rendering
```typescript
{!pathname?.startsWith('/settings') && (
<Command.Item onSelect={() => onNavigate('/settings')}>
Settings
</Command.Item>
)}
```
### External Links
```typescript
<Command.Item
onSelect={() => onExternalLink('https://example.com')}
>
External Link
</Command.Item>
```
## Testing Considerations
When testing CommandMenu features:
1. **Context Detection**: Test pathname matching
```typescript
expect(detectContext('/agent/123')).toEqual({ type: 'agent', name: 'Agent' });
```
2. **Navigation Stack**: Test page state management
```typescript
navigateToPage('theme');
expect(pages).toEqual(['theme']);
handleBack();
expect(pages).toEqual([]);
```
3. **Search Debouncing**: Mock timers or use `vi.advanceTimersByTime(300)`
4. **Portal Rendering**: Use `screen.getByRole('dialog')` or similar
5. **Keyboard Events**: Simulate with `fireEvent.keyDown(element, { key: 'Escape' })`
## Performance Notes
1. **Debounced Search**: 300ms delay prevents excessive API calls
2. **SWR Caching**: Search results cached, no refetch on focus/reconnect
3. **Memoization**: All submenu components use `memo()` to prevent re-renders
4. **Portal**: Renders outside main React tree for better performance
5. **Conditional Rendering**: Only renders when `open && mounted`
## Future Improvements
Potential areas for enhancement:
1. **Recent Commands**: Track and show recently used commands
2. **Custom Shortcuts**: Allow users to assign custom keyboard shortcuts
3. **Command History**: Navigate through previous searches
4. **AI Integration**: Actually connect AI mode to backend chat service
5. **Plugin System**: Allow extensions to register custom commands
6. **Themes**: More theme options beyond light/dark/auto
7. **Search Scoping**: Filter search by type (agents only, files only, etc.)
8. **Workspace-specific**: Different commands per workspace/project
---
## Quick Reference
**Open Menu**: `Cmd/Ctrl + K`
**Main Files**:
- `index.tsx` - Main orchestration
- `useCommandMenu.ts` - Business logic
- `MainMenu.tsx` - Default commands
- `SearchResults.tsx` - Search display
- `utils/context.ts` - Context detection
- `utils/contextCommands.ts` - Context commands
**Key Dependencies**:
- `cmdk` - Command palette primitives
- `react-router-dom` - Navigation
- `zustand` - Global state
- `swr` - Data fetching
- `antd-style` - Styling
- `lucide-react` - Icons
**Related Documentation**:
- [cmdk docs](https://cmdk.paco.me/)
- [Zustand docs](https://zustand-demo.pmnd.rs/)
- [SWR docs](https://swr.vercel.app/)
+582
View File
@@ -0,0 +1,582 @@
import { Command } from 'cmdk';
import dayjs from 'dayjs';
import { Bot, FileText, MessageCircle, MessageSquare, Plug, Puzzle, Sparkles } from 'lucide-react';
import { markdownToTxt } from 'markdown-to-txt';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import type { SearchResult } from '@/database/repositories/search';
import type { Context } from './types';
interface SearchResultsProps {
context?: Context;
isLoading: boolean;
onClose: () => void;
results: SearchResult[];
searchQuery?: string;
styles: any;
}
/**
* Search results from unified search index.
*/
const SearchResults = memo<SearchResultsProps>(
({ results, isLoading, onClose, styles, context, searchQuery = '' }) => {
const { t } = useTranslation('common');
const navigate = useNavigate();
const handleNavigate = (result: SearchResult) => {
switch (result.type) {
case 'agent': {
navigate(`/agent/${result.id}?agent=${result.id}`);
break;
}
case 'topic': {
if (result.agentId) {
navigate(`/agent/${result.agentId}?topic=${result.id}`);
} else {
navigate(`/chat?topic=${result.id}`);
}
break;
}
case 'message': {
// Navigate to the topic/agent where the message is
if (result.topicId && result.agentId) {
navigate(`/agent/${result.agentId}?topic=${result.topicId}#${result.id}`);
} else if (result.topicId) {
navigate(`/chat?topic=${result.topicId}#${result.id}`);
} else if (result.agentId) {
navigate(`/agent/${result.agentId}#${result.id}`);
} else {
navigate(`/chat#${result.id}`);
}
break;
}
case 'file': {
// Navigate to resource library with file parameter
if (result.knowledgeBaseId) {
navigate(`/resource/library/${result.knowledgeBaseId}?file=${result.id}`);
} else {
// Fallback to library root if no knowledge base
navigate(`/resource/library?file=${result.id}`);
}
break;
}
case 'page': {
navigate(`/page/${result.id}`);
break;
}
case 'mcp': {
navigate(`/community/mcp/${result.identifier}`);
break;
}
case 'plugin': {
navigate(`/community/plugins/${result.identifier}`);
break;
}
case 'assistant': {
navigate(`/community/assistant/${result.identifier}`);
break;
}
}
onClose();
};
const getIcon = (type: SearchResult['type']) => {
switch (type) {
case 'agent': {
return <Sparkles size={16} />;
}
case 'topic': {
return <MessageSquare size={16} />;
}
case 'message': {
return <MessageCircle size={16} />;
}
case 'file': {
return <FileText size={16} />;
}
case 'page': {
return <FileText size={16} />;
}
case 'mcp': {
return <Puzzle size={16} />;
}
case 'plugin': {
return <Plug size={16} />;
}
case 'assistant': {
return <Bot size={16} />;
}
}
};
const getTypeLabel = (type: SearchResult['type']) => {
switch (type) {
case 'agent': {
return t('cmdk.search.agent');
}
case 'topic': {
return t('cmdk.search.topic');
}
case 'message': {
return t('cmdk.search.message');
}
case 'file': {
return t('cmdk.search.file');
}
case 'page': {
return t('cmdk.search.page');
}
case 'mcp': {
return t('cmdk.search.mcp');
}
case 'plugin': {
return t('cmdk.search.plugin');
}
case 'assistant': {
return t('cmdk.search.assistant');
}
}
};
// eslint-disable-next-line unicorn/consistent-function-scoping
const getItemValue = (result: SearchResult) => {
const meta = [result.title, result.description].filter(Boolean).join(' ');
// Prefix with "search-result" to ensure these items rank after built-in commands
// Include ID to ensure uniqueness when multiple items have the same title
return `search-result ${result.type} ${result.id} ${meta}`.trim();
};
// eslint-disable-next-line unicorn/consistent-function-scoping
const getDescription = (result: SearchResult) => {
if (!result.description) return null;
// Sanitize markdown content for message search results
if (result.type === 'message') {
return markdownToTxt(result.description);
}
return result.description;
};
// eslint-disable-next-line unicorn/consistent-function-scoping
const getSubtitle = (result: SearchResult) => {
const description = getDescription(result);
// For topic and message results, append creation date
if (result.type === 'topic' || result.type === 'message') {
const formattedDate = dayjs(result.createdAt).format('MMM D, YYYY');
if (description) {
return `${description} · ${formattedDate}`;
}
return formattedDate;
}
return description;
};
const hasResults = results.length > 0;
// Group results by type
const messageResults = results.filter((r) => r.type === 'message');
const agentResults = results.filter((r) => r.type === 'agent');
const topicResults = results.filter((r) => r.type === 'topic');
const fileResults = results.filter((r) => r.type === 'file');
const pageResults = results.filter((r) => r.type === 'page');
const mcpResults = results.filter((r) => r.type === 'mcp');
const pluginResults = results.filter((r) => r.type === 'plugin');
const assistantResults = results.filter((r) => r.type === 'assistant');
// Detect context types
const isResourceContext = context?.type === 'resource';
const isPageContext = context?.type === 'page';
// Don't render anything if no results and not loading
if (!hasResults && !isLoading) {
return null;
}
return (
<>
{/* Show pages first in page context */}
{hasResults && isPageContext && pageResults.length > 0 && (
<Command.Group heading={t('cmdk.search.pages')} key="pages-page-context">
{pageResults.map((result) => (
<Command.Item
key={`page-page-context-${result.id}`}
onSelect={() => handleNavigate(result)}
value={getItemValue(result)}
>
<div className={styles.itemContent}>
<div className={styles.itemIcon}>{getIcon(result.type)}</div>
<div className={styles.itemDetails}>
<div className={styles.itemTitle}>{result.title}</div>
{result.description && (
<div className={styles.itemDescription}>{result.description}</div>
)}
</div>
<div className={styles.itemType}>{getTypeLabel(result.type)}</div>
</div>
</Command.Item>
))}
</Command.Group>
)}
{/* Show other results in page context */}
{hasResults && isPageContext && fileResults.length > 0 && (
<Command.Group heading={t('cmdk.search.files')}>
{fileResults.map((result) => (
<Command.Item
key={`file-page-context-${result.id}`}
onSelect={() => handleNavigate(result)}
value={getItemValue(result)}
>
<div className={styles.itemContent}>
<div className={styles.itemIcon}>{getIcon(result.type)}</div>
<div className={styles.itemDetails}>
<div className={styles.itemTitle}>{result.title}</div>
{result.type === 'file' && (
<div className={styles.itemDescription}>{result.fileType}</div>
)}
</div>
<div className={styles.itemType}>{getTypeLabel(result.type)}</div>
</div>
</Command.Item>
))}
</Command.Group>
)}
{hasResults && isPageContext && agentResults.length > 0 && (
<Command.Group heading={t('cmdk.search.agents')}>
{agentResults.map((result) => (
<Command.Item
key={`agent-page-context-${result.id}`}
onSelect={() => handleNavigate(result)}
value={getItemValue(result)}
>
<div className={styles.itemContent}>
<div className={styles.itemIcon}>{getIcon(result.type)}</div>
<div className={styles.itemDetails}>
<div className={styles.itemTitle}>{result.title}</div>
{getDescription(result) && (
<div className={styles.itemDescription}>{getDescription(result)}</div>
)}
</div>
<div className={styles.itemType}>{getTypeLabel(result.type)}</div>
</div>
</Command.Item>
))}
</Command.Group>
)}
{hasResults && isPageContext && topicResults.length > 0 && (
<Command.Group heading={t('cmdk.search.topics')}>
{topicResults.map((result) => (
<Command.Item
key={`topic-page-context-${result.id}`}
onSelect={() => handleNavigate(result)}
value={getItemValue(result)}
>
<div className={styles.itemContent}>
<div className={styles.itemIcon}>{getIcon(result.type)}</div>
<div className={styles.itemDetails}>
<div className={styles.itemTitle}>{result.title}</div>
{getSubtitle(result) && (
<div className={styles.itemDescription}>{getSubtitle(result)}</div>
)}
</div>
<div className={styles.itemType}>{getTypeLabel(result.type)}</div>
</div>
</Command.Item>
))}
</Command.Group>
)}
{hasResults && isPageContext && messageResults.length > 0 && (
<Command.Group heading={t('cmdk.search.messages')}>
{messageResults.map((result) => (
<Command.Item
key={`message-page-context-${result.id}`}
onSelect={() => handleNavigate(result)}
value={getItemValue(result)}
>
<div className={styles.itemContent}>
<div className={styles.itemIcon}>{getIcon(result.type)}</div>
<div className={styles.itemDetails}>
<div className={styles.itemTitle}>{result.title}</div>
{getSubtitle(result) && (
<div className={styles.itemDescription}>{getSubtitle(result)}</div>
)}
</div>
<div className={styles.itemType}>{getTypeLabel(result.type)}</div>
</div>
</Command.Item>
))}
</Command.Group>
)}
{/* Show pages first in resource context */}
{hasResults && isResourceContext && pageResults.length > 0 && (
<Command.Group heading={t('cmdk.search.pages')} key="pages-resource">
{pageResults.map((result) => (
<Command.Item
key={`page-resource-${result.id}`}
onSelect={() => handleNavigate(result)}
value={getItemValue(result)}
>
<div className={styles.itemContent}>
<div className={styles.itemIcon}>{getIcon(result.type)}</div>
<div className={styles.itemDetails}>
<div className={styles.itemTitle}>{result.title}</div>
{result.description && (
<div className={styles.itemDescription}>{result.description}</div>
)}
</div>
<div className={styles.itemType}>{getTypeLabel(result.type)}</div>
</div>
</Command.Item>
))}
</Command.Group>
)}
{/* Show files in resource context */}
{hasResults && isResourceContext && fileResults.length > 0 && (
<Command.Group heading={t('cmdk.search.files')}>
{fileResults.map((result) => (
<Command.Item
key={`file-${result.id}`}
onSelect={() => handleNavigate(result)}
value={getItemValue(result)}
>
<div className={styles.itemContent}>
<div className={styles.itemIcon}>{getIcon(result.type)}</div>
<div className={styles.itemDetails}>
<div className={styles.itemTitle}>{result.title}</div>
{result.type === 'file' && (
<div className={styles.itemDescription}>{result.fileType}</div>
)}
</div>
<div className={styles.itemType}>{getTypeLabel(result.type)}</div>
</div>
</Command.Item>
))}
</Command.Group>
)}
{hasResults && messageResults.length > 0 && (
<Command.Group heading={t('cmdk.search.messages')}>
{messageResults.map((result) => (
<Command.Item
key={`message-${result.id}`}
onSelect={() => handleNavigate(result)}
value={getItemValue(result)}
>
<div className={styles.itemContent}>
<div className={styles.itemIcon}>{getIcon(result.type)}</div>
<div className={styles.itemDetails}>
<div className={styles.itemTitle}>{result.title}</div>
{getSubtitle(result) && (
<div className={styles.itemDescription}>{getSubtitle(result)}</div>
)}
</div>
<div className={styles.itemType}>{getTypeLabel(result.type)}</div>
</div>
</Command.Item>
))}
</Command.Group>
)}
{hasResults && agentResults.length > 0 && (
<Command.Group heading={t('cmdk.search.agents')}>
{agentResults.map((result) => (
<Command.Item
key={`agent-${result.id}`}
onSelect={() => handleNavigate(result)}
value={getItemValue(result)}
>
<div className={styles.itemContent}>
<div className={styles.itemIcon}>{getIcon(result.type)}</div>
<div className={styles.itemDetails}>
<div className={styles.itemTitle}>{result.title}</div>
{getDescription(result) && (
<div className={styles.itemDescription}>{getDescription(result)}</div>
)}
</div>
<div className={styles.itemType}>{getTypeLabel(result.type)}</div>
</div>
</Command.Item>
))}
</Command.Group>
)}
{hasResults && topicResults.length > 0 && (
<Command.Group heading={t('cmdk.search.topics')}>
{topicResults.map((result) => (
<Command.Item
key={`topic-${result.id}`}
onSelect={() => handleNavigate(result)}
value={getItemValue(result)}
>
<div className={styles.itemContent}>
<div className={styles.itemIcon}>{getIcon(result.type)}</div>
<div className={styles.itemDetails}>
<div className={styles.itemTitle}>{result.title}</div>
{getSubtitle(result) && (
<div className={styles.itemDescription}>{getSubtitle(result)}</div>
)}
</div>
<div className={styles.itemType}>{getTypeLabel(result.type)}</div>
</div>
</Command.Item>
))}
</Command.Group>
)}
{/* Show document pages in normal context (not in resource or page context) */}
{hasResults && !isResourceContext && !isPageContext && pageResults.length > 0 && (
<Command.Group heading={t('cmdk.search.pages')} key="pages-normal">
{pageResults.map((result) => (
<Command.Item
key={`page-normal-${result.id}`}
onSelect={() => handleNavigate(result)}
value={getItemValue(result)}
>
<div className={styles.itemContent}>
<div className={styles.itemIcon}>{getIcon(result.type)}</div>
<div className={styles.itemDetails}>
<div className={styles.itemTitle}>{result.title}</div>
{result.description && (
<div className={styles.itemDescription}>{result.description}</div>
)}
</div>
<div className={styles.itemType}>{getTypeLabel(result.type)}</div>
</div>
</Command.Item>
))}
</Command.Group>
)}
{/* Show files in original position when NOT in resource or page context */}
{hasResults && !isResourceContext && !isPageContext && fileResults.length > 0 && (
<Command.Group heading={t('cmdk.search.files')}>
{fileResults.map((result) => (
<Command.Item
key={`file-${result.id}`}
onSelect={() => handleNavigate(result)}
value={getItemValue(result)}
>
<div className={styles.itemContent}>
<div className={styles.itemIcon}>{getIcon(result.type)}</div>
<div className={styles.itemDetails}>
<div className={styles.itemTitle}>{result.title}</div>
{result.type === 'file' && (
<div className={styles.itemDescription}>{result.fileType}</div>
)}
</div>
<div className={styles.itemType}>{getTypeLabel(result.type)}</div>
</div>
</Command.Item>
))}
</Command.Group>
)}
{hasResults && mcpResults.length > 0 && (
<Command.Group heading={t('cmdk.search.mcps')}>
{mcpResults.map((result) => (
<Command.Item
key={`mcp-${result.id}`}
onSelect={() => handleNavigate(result)}
value={getItemValue(result)}
>
<div className={styles.itemContent}>
<div className={styles.itemIcon}>{getIcon(result.type)}</div>
<div className={styles.itemDetails}>
<div className={styles.itemTitle}>{result.title}</div>
{getDescription(result) && (
<div className={styles.itemDescription}>{getDescription(result)}</div>
)}
</div>
<div className={styles.itemType}>{getTypeLabel(result.type)}</div>
</div>
</Command.Item>
))}
</Command.Group>
)}
{hasResults && pluginResults.length > 0 && (
<Command.Group heading={t('cmdk.search.plugins')}>
{pluginResults.map((result) => (
<Command.Item
key={`plugin-${result.id}`}
onSelect={() => handleNavigate(result)}
value={getItemValue(result)}
>
<div className={styles.itemContent}>
<div className={styles.itemIcon}>{getIcon(result.type)}</div>
<div className={styles.itemDetails}>
<div className={styles.itemTitle}>{result.title}</div>
{getDescription(result) && (
<div className={styles.itemDescription}>{getDescription(result)}</div>
)}
</div>
<div className={styles.itemType}>{getTypeLabel(result.type)}</div>
</div>
</Command.Item>
))}
</Command.Group>
)}
{hasResults && assistantResults.length > 0 && (
<Command.Group heading={t('cmdk.search.assistants')}>
{assistantResults.map((result) => (
<Command.Item
key={`assistant-${result.id}`}
onSelect={() => handleNavigate(result)}
value={getItemValue(result)}
>
<div className={styles.itemContent}>
<div className={styles.itemIcon}>{getIcon(result.type)}</div>
<div className={styles.itemDetails}>
<div className={styles.itemTitle}>{result.title}</div>
{getDescription(result) && (
<div className={styles.itemDescription}>{getDescription(result)}</div>
)}
</div>
<div className={styles.itemType}>{getTypeLabel(result.type)}</div>
</div>
</Command.Item>
))}
</Command.Group>
)}
{/* Show loading skeleton below existing results */}
{isLoading && (
<Command.Group>
{[1, 2, 3].map((i) => (
<Command.Item
disabled
key={`skeleton-${i}`}
keywords={[searchQuery]}
value={`${searchQuery}-loading-skeleton-${i}`}
>
<div className={styles.skeleton} style={{ height: 20, width: 20 }} />
<div style={{ display: 'flex', flex: 1, flexDirection: 'column', gap: 4 }}>
<div className={styles.skeleton} style={{ width: `${60 + i * 10}%` }} />
<div
className={styles.skeleton}
style={{ height: 12, width: `${40 + i * 5}%` }}
/>
</div>
</Command.Item>
))}
</Command.Group>
)}
</>
);
},
);
SearchResults.displayName = 'SearchResults';
export default SearchResults;
+46
View File
@@ -0,0 +1,46 @@
import { Command } from 'cmdk';
import { Monitor, Moon, Sun } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import type { ThemeMode } from './types';
interface ThemeMenuProps {
onThemeChange: (theme: ThemeMode) => void;
styles: {
icon: string;
itemContent: string;
itemLabel: string;
};
}
const ThemeMenu = memo<ThemeMenuProps>(({ onThemeChange, styles }) => {
const { t } = useTranslation('common');
return (
<>
<Command.Item onSelect={() => onThemeChange('light')} value="theme-light">
<Sun className={styles.icon} />
<div className={styles.itemContent}>
<div className={styles.itemLabel}>{t('cmdk.themeLight')}</div>
</div>
</Command.Item>
<Command.Item onSelect={() => onThemeChange('dark')} value="theme-dark">
<Moon className={styles.icon} />
<div className={styles.itemContent}>
<div className={styles.itemLabel}>{t('cmdk.themeDark')}</div>
</div>
</Command.Item>
<Command.Item onSelect={() => onThemeChange('auto')} value="theme-auto">
<Monitor className={styles.icon} />
<div className={styles.itemContent}>
<div className={styles.itemLabel}>{t('cmdk.themeAuto')}</div>
</div>
</Command.Item>
</>
);
});
ThemeMenu.displayName = 'ThemeMenu';
export default ThemeMenu;
@@ -0,0 +1,64 @@
import { createStyles } from 'antd-style';
import { ArrowUpDown, CornerDownLeft } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
const useStyles = createStyles(({ css, token }) => ({
commandFooter: css`
display: flex;
gap: 16px;
align-items: center;
justify-content: flex-end;
padding-block: 8px;
padding-inline: 16px;
border-block-start: 1px solid ${token.colorBorderSecondary};
background: ${token.colorBgContainer};
`,
kbd: css`
display: inline-flex;
gap: 4px;
align-items: center;
padding-block: 2px;
padding-inline: 6px;
border-radius: ${token.borderRadiusSM}px;
font-size: 11px;
font-weight: 500;
line-height: 1.2;
color: ${token.colorTextSecondary};
background: ${token.colorFillQuaternary};
`,
kbdIcon: css`
width: 12px;
height: 12px;
`,
}));
/**
* Show avaialble keyboard action for the CMDK Menu.
*/
const CommandFooter = memo(() => {
const { t } = useTranslation('common');
const { styles } = useStyles();
return (
<div className={styles.commandFooter}>
<div className={styles.kbd}>
<CornerDownLeft className={styles.kbdIcon} />
<span>{t('cmdk.toOpen')}</span>
</div>
<div className={styles.kbd}>
<ArrowUpDown className={styles.kbdIcon} />
<span>{t('cmdk.toSelect')}</span>
</div>
</div>
);
});
CommandFooter.displayName = 'CommandFooter';
export default CommandFooter;
@@ -0,0 +1,116 @@
import { Tag } from '@lobehub/ui';
import { createStyles } from 'antd-style';
import { Command } from 'cmdk';
import { ArrowLeft } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import type { Context } from '../types';
const useStyles = createStyles(({ css, token }) => ({
backTag: css`
cursor: pointer;
&:hover {
opacity: 0.8;
}
`,
contextTag: css`
cursor: default;
user-select: none;
`,
contextWrapper: css`
padding-block: 12px 6px;
padding-inline: 16px;
`,
inputWrapper: css`
display: flex;
gap: 8px;
align-items: center;
padding: 16px;
border-block-end: 1px solid ${token.colorBorderSecondary};
`,
}));
interface CommandInputProps {
context?: Context;
hasPages: boolean;
isAiMode: boolean;
onBack: () => void;
onValueChange: (value: string) => void;
search: string;
}
const CommandInput = memo<CommandInputProps>(
({ context, hasPages, isAiMode, onBack, onValueChange, search }) => {
const { t } = useTranslation('common');
const { t: tSetting } = useTranslation('setting');
const { t: tChat } = useTranslation('chat');
const { styles } = useStyles();
// Get localized context name
const getContextName = () => {
if (!context) return undefined;
switch (context.type) {
case 'settings': {
return tSetting('header.title', { defaultValue: context.name });
}
case 'agent': {
return t('cmdk.search.agent', { defaultValue: context.name });
}
case 'group': {
return tChat('group.title', { defaultValue: context.name });
}
case 'page': {
return t('cmdk.pages', { defaultValue: context.name });
}
case 'painting': {
return t('cmdk.painting', { defaultValue: context.name });
}
case 'resource': {
return t('cmdk.resource', { defaultValue: context.name });
}
default: {
return context.name;
}
}
};
const contextName = getContextName();
return (
<>
{context && !hasPages && (
<div className={styles.contextWrapper}>
<Tag className={styles.contextTag}>{contextName}</Tag>
</div>
)}
<div className={styles.inputWrapper}>
{hasPages && (
<Tag className={styles.backTag} icon={<ArrowLeft size={12} />} onClick={onBack} />
)}
<Command.Input
autoFocus
onValueChange={onValueChange}
placeholder={isAiMode ? t('cmdk.aiModePlaceholder') : t('cmdk.searchPlaceholder')}
value={search}
/>
{!isAiMode && search.trim() ? (
<>
<span style={{ fontSize: '14px', opacity: 0.6 }}>Ask AI</span>
<Tag>Tab</Tag>
</>
) : (
<Tag>ESC</Tag>
)}
</div>
</>
);
},
);
CommandInput.displayName = 'CommandInput';
export default CommandInput;
+136
View File
@@ -0,0 +1,136 @@
'use client';
import { Command } from 'cmdk';
import { memo } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import MainMenu from './MainMenu';
import SearchResults from './SearchResults';
import ThemeMenu from './ThemeMenu';
import CommandFooter from './components/CommandFooter';
import CommandInput from './components/CommandInput';
import { useStyles } from './styles';
import { useCommandMenu } from './useCommandMenu';
// type MenuViewMode = 'default' | 'search' | 'ai-chat';
/**
* CMDK Menu.
*
* Search everything in LobeHub.
*/
const CommandMenu = memo(() => {
const { t } = useTranslation('common');
const { styles } = useStyles();
const {
closeCommandMenu,
context,
handleAskAI,
handleAskAISubmit,
handleBack,
handleCreateSession,
handleExternalLink,
handleNavigate,
handleThemeChange,
hasSearch,
isAiMode,
isSearching,
mounted,
navigateToPage,
open,
page,
pages,
pathname,
search,
searchResults,
setPages,
setSearch,
} = useCommandMenu();
if (!mounted || !open) return null;
return createPortal(
<div className={styles.overlay} onClick={closeCommandMenu}>
<div onClick={(e) => e.stopPropagation()}>
<Command
className={styles.commandRoot}
onKeyDown={(e) => {
// Tab key to ask AI when not in AI mode
if (e.key === 'Tab' && !isAiMode) {
e.preventDefault();
handleAskAI();
return;
}
// Enter key in AI mode to submit
if (e.key === 'Enter' && isAiMode) {
e.preventDefault();
handleAskAISubmit();
return;
}
// Escape goes to previous page or closes
if (e.key === 'Escape') {
e.preventDefault();
if (pages.length > 0) {
handleBack();
} else {
closeCommandMenu();
}
}
// Backspace goes to previous page when search is empty
if (e.key === 'Backspace' && !search && pages.length > 0) {
e.preventDefault();
setPages((prev) => prev.slice(0, -1));
}
}}
shouldFilter={!isAiMode}
>
<CommandInput
context={context}
hasPages={pages.length > 0}
isAiMode={isAiMode}
onBack={handleBack}
onValueChange={setSearch}
search={search}
/>
<Command.List>
{!isAiMode && !isSearching && <Command.Empty>{t('cmdk.noResults')}</Command.Empty>}
{!page && (
<MainMenu
context={context}
onCreateSession={handleCreateSession}
onExternalLink={handleExternalLink}
onNavigate={handleNavigate}
onNavigateToTheme={() => navigateToPage('theme')}
pathname={pathname}
styles={styles}
/>
)}
{page === 'theme' && <ThemeMenu onThemeChange={handleThemeChange} styles={styles} />}
{!page && hasSearch && !isAiMode && (
<SearchResults
context={context}
isLoading={isSearching}
onClose={closeCommandMenu}
results={searchResults}
searchQuery={search}
styles={styles}
/>
)}
</Command.List>
<CommandFooter />
</Command>
</div>
</div>,
document.body,
);
});
CommandMenu.displayName = 'CommandMenu';
export default CommandMenu;
+253
View File
@@ -0,0 +1,253 @@
import { createStyles } from 'antd-style';
export const useStyles = createStyles(({ css, token }) => ({
chatContainer: css`
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
`,
chatMessage: css`
display: flex;
flex-direction: column;
gap: 4px;
padding: 12px;
border-radius: ${token.borderRadius}px;
background: ${token.colorBgContainer};
`,
chatMessageContent: css`
font-size: 14px;
line-height: 1.6;
color: ${token.colorText};
`,
chatMessageRole: css`
font-size: 12px;
font-weight: 500;
color: ${token.colorTextSecondary};
text-transform: uppercase;
`,
commandContainer: css`
display: flex;
flex-direction: column;
`,
commandRoot: css`
overflow: hidden;
display: flex;
flex-direction: column;
width: min(640px, 90vw);
max-height: min(500px, 70vh);
border-radius: ${token.borderRadiusLG}px;
background: ${token.colorBgElevated};
box-shadow: ${token.boxShadowSecondary};
animation: slide-down 0.12s ease-out;
@keyframes slide-down {
from {
transform: translateY(-20px) scale(0.96);
opacity: 0;
}
to {
transform: translateY(0) scale(1);
opacity: 1;
}
}
[cmdk-input] {
flex: 1;
min-width: 0;
padding: 0;
border: none;
font-family: inherit;
font-size: 16px;
color: ${token.colorText};
background: transparent;
outline: none;
&::placeholder {
color: ${token.colorTextPlaceholder};
}
}
[cmdk-list] {
overflow-y: auto;
max-height: 400px;
padding: 8px;
}
[cmdk-empty] {
padding-block: 32px;
padding-inline: 16px;
font-size: 14px;
color: ${token.colorTextTertiary};
text-align: center;
}
[cmdk-item] {
cursor: pointer;
user-select: none;
display: flex;
gap: 12px;
align-items: center;
padding-block: 12px;
padding-inline: 16px;
border-radius: ${token.borderRadius}px;
color: ${token.colorText};
transition: all 0.15s ease;
&[aria-selected='true'] {
background: ${token.colorBgTextHover};
}
&:hover {
background: ${token.colorBgTextHover};
}
}
[cmdk-group-heading] {
user-select: none;
padding-block: 8px;
padding-inline: 16px;
font-size: 12px;
font-weight: 500;
color: ${token.colorTextSecondary};
}
[cmdk-separator] {
height: 1px;
margin-block: 4px;
background: ${token.colorBorderSecondary};
}
`,
icon: css`
flex-shrink: 0;
width: 20px;
height: 20px;
color: ${token.colorTextSecondary};
`,
itemContent: css`
display: flex;
flex: 1;
gap: 12px;
align-items: center;
min-width: 0;
`,
itemDescription: css`
overflow: hidden;
margin-block-start: 2px;
font-size: 12px;
line-height: 1.4;
color: ${token.colorTextTertiary};
text-overflow: ellipsis;
white-space: nowrap;
`,
itemDetails: css`
flex: 1;
min-width: 0;
`,
itemIcon: css`
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
color: ${token.colorTextSecondary};
`,
itemLabel: css`
font-size: 14px;
font-weight: 500;
line-height: 1.4;
`,
itemTitle: css`
overflow: hidden;
font-size: 14px;
font-weight: 500;
line-height: 1.4;
text-overflow: ellipsis;
white-space: nowrap;
`,
itemType: css`
flex-shrink: 0;
font-size: 11px;
color: ${token.colorTextTertiary};
text-transform: capitalize;
`,
overlay: css`
position: fixed;
z-index: 9999;
inset: 0;
display: flex;
justify-content: center;
padding-block-start: 15vh;
background: ${token.colorBgMask};
animation: fade-in 0.1s ease-in-out;
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
`,
skeleton: css`
height: 16px;
border-radius: ${token.borderRadiusSM}px;
background: linear-gradient(
90deg,
${token.colorFillSecondary} 25%,
${token.colorFillTertiary} 50%,
${token.colorFillSecondary} 75%
);
background-size: 200% 100%;
animation: pulse 1.5s ease-in-out infinite;
@keyframes pulse {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
`,
skeletonItem: css`
display: flex;
gap: 12px;
align-items: center;
padding-block: 12px;
padding-inline: 16px;
`,
}));
+17
View File
@@ -0,0 +1,17 @@
export interface ChatMessage {
content: string;
id: string;
role: 'user' | 'assistant';
}
export type ThemeMode = 'light' | 'dark' | 'auto';
export type PageType = 'theme' | 'ai-chat' | string;
export interface Context {
name: string;
subPath?: string;
type: ContextType;
}
export type ContextType = 'agent' | 'group' | 'painting' | 'settings' | 'resource' | 'page';
+167
View File
@@ -0,0 +1,167 @@
import { useDebounce } from 'ahooks';
import { useEffect, useMemo, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import useSWR from 'swr';
import type { SearchResult } from '@/database/repositories/search';
import { lambdaClient } from '@/libs/trpc/client';
import { useAgentStore } from '@/store/agent';
import { builtinAgentSelectors } from '@/store/agent/selectors/builtinAgentSelectors';
import { useGlobalStore } from '@/store/global';
import { globalHelpers } from '@/store/global/helpers';
import { useHomeStore } from '@/store/home';
import type { ThemeMode } from './types';
import { detectContext } from './utils/context';
export const useCommandMenu = () => {
const [open, setOpen] = useGlobalStore((s) => [s.status.showCommandMenu, s.updateSystemStatus]);
const [mounted, setMounted] = useState(false);
const [search, setSearch] = useState('');
const [pages, setPages] = useState<string[]>([]);
const navigate = useNavigate();
const location = useLocation();
const pathname = location.pathname;
const switchThemeMode = useGlobalStore((s) => s.switchThemeMode);
const createAgent = useAgentStore((s) => s.createAgent);
const refreshAgentList = useHomeStore((s) => s.refreshAgentList);
const inboxAgentId = useAgentStore(builtinAgentSelectors.inboxAgentId);
const page = pages.at(-1);
const isAiMode = page === 'ai-chat';
// Detect context based on current pathname
const context = useMemo(() => detectContext(pathname), [pathname]);
// Extract agentId from pathname when in agent context
const agentId = useMemo(() => {
if (context?.type === 'agent') {
const match = pathname.match(/^\/agent\/([^/?]+)/);
return match?.[1] || undefined;
}
return undefined;
}, [context, pathname]);
// Debounce search input to reduce API calls
const debouncedSearch = useDebounce(search, { wait: 600 });
// Search functionality
const hasSearch = debouncedSearch.trim().length > 0;
const searchQuery = debouncedSearch.trim();
const { data: searchResults, isLoading: isSearching } = useSWR<SearchResult[]>(
hasSearch && !isAiMode ? ['search', searchQuery, agentId] : null,
async () => {
const locale = globalHelpers.getCurrentLanguage();
return lambdaClient.search.query.query({ agentId, locale, query: searchQuery });
},
{
revalidateOnFocus: false,
revalidateOnReconnect: false,
},
);
// Ensure we're mounted on the client
useEffect(() => {
setMounted(true);
}, []);
// Close on Escape key and prevent body scroll
useEffect(() => {
if (open) {
const originalStyle = window.getComputedStyle(document.body).overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = originalStyle;
};
}
}, [open]);
// Reset pages and search when opening/closing
useEffect(() => {
if (open) {
setPages([]);
setSearch('');
}
}, [open]);
const closeCommandMenu = () => {
setOpen({ showCommandMenu: false });
};
const handleNavigate = (path: string) => {
navigate(path);
closeCommandMenu();
};
const handleExternalLink = (url: string) => {
window.open(url, '_blank', 'noopener,noreferrer');
closeCommandMenu();
};
const handleThemeChange = (theme: ThemeMode) => {
switchThemeMode(theme);
closeCommandMenu();
};
const handleAskAI = () => {
// Enter AI mode without adding messages
setPages([...pages, 'ai-chat']);
};
const handleAskAISubmit = () => {
// Navigate to inbox agent with the message query parameter
if (inboxAgentId && search.trim()) {
const message = encodeURIComponent(search.trim());
navigate(`/agent/${inboxAgentId}?message=${message}`);
closeCommandMenu();
}
};
const handleBack = () => {
setPages((prev) => prev.slice(0, -1));
};
const handleCreateSession = async () => {
const result = await createAgent({});
await refreshAgentList();
// Navigate to the newly created agent
if (result.agentId) {
navigate(`/agent/${result.agentId}`);
}
closeCommandMenu();
};
const navigateToPage = (pageName: string) => {
setPages([...pages, pageName]);
};
return {
closeCommandMenu,
context,
handleAskAI,
handleAskAISubmit,
handleBack,
handleCreateSession,
handleExternalLink,
handleNavigate,
handleThemeChange,
hasSearch,
isAiMode,
isSearching,
mounted,
navigateToPage,
open,
page,
pages,
pathname,
search,
searchResults: searchResults || ([] as SearchResult[]),
setPages,
setSearch,
};
};
+77
View File
@@ -0,0 +1,77 @@
import type { Context, ContextType } from '../types';
/**
* Configuration for context detection
*/
interface ContextConfig {
captureSubPath?: boolean;
matcher: RegExp;
name: string;
type: ContextType;
}
/**
* Context configurations - easily extensible for adding new contexts
*/
const CONTEXT_CONFIGS: ContextConfig[] = [
{
matcher: /^\/agent\/[^/]+$/,
name: 'Agent',
type: 'agent',
},
{
matcher: /^\/group\/[^/]+$/,
name: 'Group',
type: 'group',
},
{
matcher: /^\/image$/,
name: 'Painting',
type: 'painting',
},
{
captureSubPath: true,
matcher: /^\/settings(?:\/([^/]+))?/,
name: 'Settings',
type: 'settings',
},
{
captureSubPath: true,
matcher: /^\/resource(?:\/([^/]+))?/,
name: 'Resource',
type: 'resource',
},
{
captureSubPath: true,
matcher: /^\/page(?:\/([^/]+))?/,
name: 'Page',
type: 'page',
},
];
/**
* Detects the current context based on pathname
* @param pathname - The current pathname from react-router
* @returns Context object if detected, undefined otherwise
*/
export const detectContext = (pathname: string): Context | undefined => {
for (const config of CONTEXT_CONFIGS) {
const match = pathname.match(config.matcher);
if (match) {
const context: Context = {
name: config.name,
type: config.type,
};
// Capture sub-path if configured
if (config.captureSubPath && match[1]) {
context.subPath = match[1];
}
return context;
}
}
return undefined;
};
@@ -0,0 +1,132 @@
import type { LucideIcon } from 'lucide-react';
import {
Brain,
ChartColumnBigIcon,
EthernetPort,
Image as ImageIcon,
Info,
KeyIcon,
KeyboardIcon,
Palette as PaletteIcon,
UserCircle,
} from 'lucide-react';
import type { ContextType } from '../types';
export interface ContextCommand {
icon: LucideIcon;
keywords: string[];
label: string;
labelKey?: string; // i18n key for the label
labelNamespace?: 'setting' | 'auth'; // i18n namespace for the label
path: string;
subPath: string;
}
/**
* Map of context types to their available commands
*/
export const CONTEXT_COMMANDS: Record<ContextType, ContextCommand[]> = {
agent: [],
group: [],
page: [],
painting: [],
resource: [],
settings: [
{
icon: UserCircle,
keywords: ['profile', 'user', 'account', 'personal'],
label: 'Profile',
labelKey: 'tab.profile',
labelNamespace: 'auth',
path: '/settings/profile',
subPath: 'profile',
},
{
icon: PaletteIcon,
keywords: ['common', 'appearance', 'theme', 'display'],
label: 'Appearance',
labelKey: 'tab.common',
labelNamespace: 'setting',
path: '/settings/common',
subPath: 'common',
},
{
icon: Brain,
keywords: ['provider', 'llm', 'model', 'ai'],
label: 'Model Provider',
labelKey: 'tab.provider',
labelNamespace: 'setting',
path: '/settings/provider',
subPath: 'provider',
},
{
icon: KeyboardIcon,
keywords: ['hotkey', 'shortcut', 'keyboard'],
label: 'Hotkeys',
labelKey: 'tab.hotkey',
labelNamespace: 'setting',
path: '/settings/hotkey',
subPath: 'hotkey',
},
{
icon: ImageIcon,
keywords: ['image', 'picture', 'photo'],
label: 'Image Settings',
labelKey: 'tab.image',
labelNamespace: 'setting',
path: '/settings/image',
subPath: 'image',
},
{
icon: EthernetPort,
keywords: ['proxy', 'network', 'connection'],
label: 'Proxy',
labelKey: 'tab.proxy',
labelNamespace: 'setting',
path: '/settings/proxy',
subPath: 'proxy',
},
{
icon: ChartColumnBigIcon,
keywords: ['stats', 'statistics', 'analytics'],
label: 'Statistics',
labelKey: 'tab.stats',
labelNamespace: 'auth',
path: '/settings/stats',
subPath: 'stats',
},
{
icon: KeyIcon,
keywords: ['apikey', 'api', 'key', 'token'],
label: 'API Keys',
labelKey: 'tab.apikey',
labelNamespace: 'auth',
path: '/settings/apikey',
subPath: 'apikey',
},
{
icon: Info,
keywords: ['about', 'version', 'info'],
label: 'About',
labelKey: 'tab.about',
labelNamespace: 'setting',
path: '/settings/about',
subPath: 'about',
},
],
};
/**
* Get context-specific commands based on context type and current sub-path
* Filters out the current page from the list
*/
export const getContextCommands = (
contextType: ContextType,
currentSubPath?: string,
): ContextCommand[] => {
const commands = CONTEXT_COMMANDS[contextType] || [];
// Filter out the current page
return commands.filter((cmd) => cmd.subPath !== currentSubPath);
};
+14 -2
View File
@@ -8,6 +8,8 @@ export interface CreateDocumentParams {
fileType?: string;
knowledgeBaseId?: string;
metadata?: Record<string, any>;
parentId?: string;
slug?: string;
title: string;
}
@@ -16,6 +18,7 @@ export interface UpdateDocumentParams {
editorData?: string;
id: string;
metadata?: Record<string, any>;
parentId?: string | null;
title?: string;
}
@@ -24,8 +27,13 @@ export class DocumentService {
return lambdaClient.document.createDocument.mutate(params);
}
async queryDocuments(): Promise<{ items: DocumentItem[]; total: number }> {
return lambdaClient.document.queryDocuments.query();
async queryDocuments(params?: {
current?: number;
fileTypes?: string[];
pageSize?: number;
sourceTypes?: string[];
}): Promise<{ items: DocumentItem[]; total: number }> {
return lambdaClient.document.queryDocuments.query(params);
}
async getDocumentById(id: string): Promise<DocumentItem | undefined> {
@@ -36,6 +44,10 @@ export class DocumentService {
await lambdaClient.document.deleteDocument.mutate({ id });
}
async deleteDocuments(ids: string[]): Promise<void> {
await lambdaClient.document.deleteDocuments.mutate({ ids });
}
async updateDocument(params: UpdateDocumentParams): Promise<void> {
await lambdaClient.document.updateDocument.mutate(params);
}
+18 -1
View File
@@ -9,12 +9,13 @@ import {
interface CreateFileParams extends Omit<UploadFileParams, 'url'> {
knowledgeBaseId?: string;
parentId?: string;
url: string;
}
export class FileService {
createFile = async (
params: UploadFileParams,
params: UploadFileParams & { parentId?: string },
knowledgeBaseId?: string,
): Promise<{ id: string; url: string }> => {
return lambdaClient.file.createFile.mutate({ ...params, knowledgeBaseId } as CreateFileParams);
@@ -61,6 +62,10 @@ export class FileService {
return lambdaClient.file.getFileItemById.query({ id });
};
getFolderBreadcrumb = async (slug: string) => {
return lambdaClient.document.getFolderBreadcrumb.query({ slug });
};
checkFileHash = async (hash: string): Promise<CheckFileHashResult> => {
return lambdaClient.file.checkFileHash.mutate({ hash });
};
@@ -68,6 +73,18 @@ export class FileService {
removeFileAsyncTask = async (id: string, type: 'embedding' | 'chunk') => {
return lambdaClient.file.removeFileAsyncTask.mutate({ id, type });
};
updateFile = async (id: string, data: { parentId?: string | null }) => {
return lambdaClient.file.updateFile.mutate({ id, ...data });
};
getRecentFiles = async (limit?: number) => {
return lambdaClient.file.recentFiles.query({ limit });
};
getRecentPages = async (limit?: number) => {
return lambdaClient.file.recentPages.query({ limit });
};
}
export const fileService = new FileService();