mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-16 04:25:59 +00:00
✨ feat: support CMD K
This commit is contained in:
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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/)
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
`,
|
||||
}));
|
||||
@@ -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';
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user