|
| 1 | +import type { ChatContext } from '@/stores/panel' |
| 2 | + |
| 3 | +/** |
| 4 | + * Mention folder types |
| 5 | + */ |
| 6 | +export type MentionFolderId = |
| 7 | + | 'chats' |
| 8 | + | 'workflows' |
| 9 | + | 'knowledge' |
| 10 | + | 'blocks' |
| 11 | + | 'workflow-blocks' |
| 12 | + | 'templates' |
| 13 | + | 'logs' |
| 14 | + |
| 15 | +/** |
| 16 | + * Menu item category types for mention menu (includes folders + docs item) |
| 17 | + */ |
| 18 | +export type MentionCategory = MentionFolderId | 'docs' |
| 19 | + |
| 20 | +/** |
| 21 | + * Configuration interface for folder types |
| 22 | + */ |
| 23 | +export interface FolderConfig<TItem = any> { |
| 24 | + /** Display title in menu */ |
| 25 | + title: string |
| 26 | + /** Data source key in useMentionData return */ |
| 27 | + dataKey: string |
| 28 | + /** Loading state key in useMentionData return */ |
| 29 | + loadingKey: string |
| 30 | + /** Ensure loaded function key in useMentionData return (optional - some folders auto-load) */ |
| 31 | + ensureLoadedKey?: string |
| 32 | + /** Extract label from an item */ |
| 33 | + getLabel: (item: TItem) => string |
| 34 | + /** Extract unique ID from an item */ |
| 35 | + getId: (item: TItem) => string |
| 36 | + /** Empty state message */ |
| 37 | + emptyMessage: string |
| 38 | + /** No match message (when filtering) */ |
| 39 | + noMatchMessage: string |
| 40 | + /** Filter function for matching query */ |
| 41 | + filterFn: (item: TItem, query: string) => boolean |
| 42 | + /** Build the ChatContext object from an item */ |
| 43 | + buildContext: (item: TItem, workflowId?: string | null) => ChatContext |
| 44 | + /** Whether to use insertAtCursor fallback when replaceActiveMentionWith fails */ |
| 45 | + useInsertFallback?: boolean |
| 46 | +} |
| 47 | + |
| 48 | +/** |
| 49 | + * Configuration for all folder types in the mention menu |
| 50 | + */ |
| 51 | +export const FOLDER_CONFIGS: Record<MentionFolderId, FolderConfig> = { |
| 52 | + chats: { |
| 53 | + title: 'Chats', |
| 54 | + dataKey: 'pastChats', |
| 55 | + loadingKey: 'isLoadingPastChats', |
| 56 | + ensureLoadedKey: 'ensurePastChatsLoaded', |
| 57 | + getLabel: (item) => item.title || 'New Chat', |
| 58 | + getId: (item) => item.id, |
| 59 | + emptyMessage: 'No past chats', |
| 60 | + noMatchMessage: 'No matching chats', |
| 61 | + filterFn: (item, q) => (item.title || 'New Chat').toLowerCase().includes(q), |
| 62 | + buildContext: (item) => ({ |
| 63 | + kind: 'past_chat', |
| 64 | + chatId: item.id, |
| 65 | + label: item.title || 'New Chat', |
| 66 | + }), |
| 67 | + useInsertFallback: false, |
| 68 | + }, |
| 69 | + workflows: { |
| 70 | + title: 'All workflows', |
| 71 | + dataKey: 'workflows', |
| 72 | + loadingKey: 'isLoadingWorkflows', |
| 73 | + // No ensureLoadedKey - workflows auto-load from registry store |
| 74 | + getLabel: (item) => item.name || 'Untitled Workflow', |
| 75 | + getId: (item) => item.id, |
| 76 | + emptyMessage: 'No workflows', |
| 77 | + noMatchMessage: 'No matching workflows', |
| 78 | + filterFn: (item, q) => (item.name || 'Untitled Workflow').toLowerCase().includes(q), |
| 79 | + buildContext: (item) => ({ |
| 80 | + kind: 'workflow', |
| 81 | + workflowId: item.id, |
| 82 | + label: item.name || 'Untitled Workflow', |
| 83 | + }), |
| 84 | + useInsertFallback: true, |
| 85 | + }, |
| 86 | + knowledge: { |
| 87 | + title: 'Knowledge Bases', |
| 88 | + dataKey: 'knowledgeBases', |
| 89 | + loadingKey: 'isLoadingKnowledge', |
| 90 | + ensureLoadedKey: 'ensureKnowledgeLoaded', |
| 91 | + getLabel: (item) => item.name || 'Untitled', |
| 92 | + getId: (item) => item.id, |
| 93 | + emptyMessage: 'No knowledge bases', |
| 94 | + noMatchMessage: 'No matching knowledge bases', |
| 95 | + filterFn: (item, q) => (item.name || 'Untitled').toLowerCase().includes(q), |
| 96 | + buildContext: (item) => ({ |
| 97 | + kind: 'knowledge', |
| 98 | + knowledgeId: item.id, |
| 99 | + label: item.name || 'Untitled', |
| 100 | + }), |
| 101 | + useInsertFallback: false, |
| 102 | + }, |
| 103 | + blocks: { |
| 104 | + title: 'Blocks', |
| 105 | + dataKey: 'blocksList', |
| 106 | + loadingKey: 'isLoadingBlocks', |
| 107 | + ensureLoadedKey: 'ensureBlocksLoaded', |
| 108 | + getLabel: (item) => item.name || item.id, |
| 109 | + getId: (item) => item.id, |
| 110 | + emptyMessage: 'No blocks found', |
| 111 | + noMatchMessage: 'No matching blocks', |
| 112 | + filterFn: (item, q) => (item.name || item.id).toLowerCase().includes(q), |
| 113 | + buildContext: (item) => ({ |
| 114 | + kind: 'blocks', |
| 115 | + blockIds: [item.id], |
| 116 | + label: item.name || item.id, |
| 117 | + }), |
| 118 | + useInsertFallback: false, |
| 119 | + }, |
| 120 | + 'workflow-blocks': { |
| 121 | + title: 'Workflow Blocks', |
| 122 | + dataKey: 'workflowBlocks', |
| 123 | + loadingKey: 'isLoadingWorkflowBlocks', |
| 124 | + // No ensureLoadedKey - workflow blocks auto-sync from store |
| 125 | + getLabel: (item) => item.name || item.id, |
| 126 | + getId: (item) => item.id, |
| 127 | + emptyMessage: 'No blocks in this workflow', |
| 128 | + noMatchMessage: 'No matching blocks', |
| 129 | + filterFn: (item, q) => (item.name || item.id).toLowerCase().includes(q), |
| 130 | + buildContext: (item, workflowId) => ({ |
| 131 | + kind: 'workflow_block', |
| 132 | + workflowId: workflowId || '', |
| 133 | + blockId: item.id, |
| 134 | + label: item.name || item.id, |
| 135 | + }), |
| 136 | + useInsertFallback: true, |
| 137 | + }, |
| 138 | + templates: { |
| 139 | + title: 'Templates', |
| 140 | + dataKey: 'templatesList', |
| 141 | + loadingKey: 'isLoadingTemplates', |
| 142 | + ensureLoadedKey: 'ensureTemplatesLoaded', |
| 143 | + getLabel: (item) => item.name || 'Untitled Template', |
| 144 | + getId: (item) => item.id, |
| 145 | + emptyMessage: 'No templates found', |
| 146 | + noMatchMessage: 'No matching templates', |
| 147 | + filterFn: (item, q) => (item.name || 'Untitled Template').toLowerCase().includes(q), |
| 148 | + buildContext: (item) => ({ |
| 149 | + kind: 'templates', |
| 150 | + templateId: item.id, |
| 151 | + label: item.name || 'Untitled Template', |
| 152 | + }), |
| 153 | + useInsertFallback: false, |
| 154 | + }, |
| 155 | + logs: { |
| 156 | + title: 'Logs', |
| 157 | + dataKey: 'logsList', |
| 158 | + loadingKey: 'isLoadingLogs', |
| 159 | + ensureLoadedKey: 'ensureLogsLoaded', |
| 160 | + getLabel: (item) => item.workflowName, |
| 161 | + getId: (item) => item.id, |
| 162 | + emptyMessage: 'No executions found', |
| 163 | + noMatchMessage: 'No matching executions', |
| 164 | + filterFn: (item, q) => |
| 165 | + [item.workflowName, item.trigger || ''].join(' ').toLowerCase().includes(q), |
| 166 | + buildContext: (item) => ({ |
| 167 | + kind: 'logs', |
| 168 | + executionId: item.executionId || item.id, |
| 169 | + label: item.workflowName, |
| 170 | + }), |
| 171 | + useInsertFallback: false, |
| 172 | + }, |
| 173 | +} |
| 174 | + |
| 175 | +/** |
| 176 | + * Order of folders in the mention menu |
| 177 | + */ |
| 178 | +export const FOLDER_ORDER: MentionFolderId[] = [ |
| 179 | + 'chats', |
| 180 | + 'workflows', |
| 181 | + 'knowledge', |
| 182 | + 'blocks', |
| 183 | + 'workflow-blocks', |
| 184 | + 'templates', |
| 185 | + 'logs', |
| 186 | +] |
| 187 | + |
| 188 | +/** |
| 189 | + * Docs item configuration (special case - not a folder) |
| 190 | + */ |
| 191 | +export const DOCS_CONFIG = { |
| 192 | + getLabel: () => 'Docs', |
| 193 | + buildContext: (): ChatContext => ({ kind: 'docs', label: 'Docs' }), |
| 194 | +} as const |
| 195 | + |
| 196 | +/** |
| 197 | + * Total number of items in root menu (folders + docs) |
| 198 | + */ |
| 199 | +export const ROOT_MENU_ITEM_COUNT = FOLDER_ORDER.length + 1 |
| 200 | + |
| 201 | +/** |
| 202 | + * Slash command configuration |
| 203 | + */ |
| 204 | +export interface SlashCommand { |
| 205 | + id: string |
| 206 | + label: string |
| 207 | +} |
| 208 | + |
| 209 | +export const TOP_LEVEL_COMMANDS: readonly SlashCommand[] = [ |
| 210 | + { id: 'fast', label: 'Fast' }, |
| 211 | + { id: 'research', label: 'Research' }, |
| 212 | + { id: 'actions', label: 'Actions' }, |
| 213 | +] as const |
| 214 | + |
| 215 | +/** |
| 216 | + * Maps UI command IDs to API command IDs. |
| 217 | + * Some commands have different IDs for display vs API (e.g., "actions" -> "superagent") |
| 218 | + */ |
| 219 | +export function getApiCommandId(uiCommandId: string): string { |
| 220 | + const commandMapping: Record<string, string> = { |
| 221 | + actions: 'superagent', |
| 222 | + } |
| 223 | + return commandMapping[uiCommandId] || uiCommandId |
| 224 | +} |
| 225 | + |
| 226 | +export const WEB_COMMANDS: readonly SlashCommand[] = [ |
| 227 | + { id: 'search', label: 'Search' }, |
| 228 | + { id: 'read', label: 'Read' }, |
| 229 | + { id: 'scrape', label: 'Scrape' }, |
| 230 | + { id: 'crawl', label: 'Crawl' }, |
| 231 | +] as const |
| 232 | + |
| 233 | +export const ALL_SLASH_COMMANDS: readonly SlashCommand[] = [...TOP_LEVEL_COMMANDS, ...WEB_COMMANDS] |
| 234 | + |
| 235 | +export const ALL_COMMAND_IDS = ALL_SLASH_COMMANDS.map((cmd) => cmd.id) |
| 236 | + |
| 237 | +/** |
| 238 | + * Get display label for a command ID |
| 239 | + */ |
| 240 | +export function getCommandDisplayLabel(commandId: string): string { |
| 241 | + const command = ALL_SLASH_COMMANDS.find((cmd) => cmd.id === commandId) |
| 242 | + return command?.label || commandId.charAt(0).toUpperCase() + commandId.slice(1) |
| 243 | +} |
| 244 | + |
| 245 | +/** |
| 246 | + * Model configuration options |
| 247 | + */ |
| 248 | +export const MODEL_OPTIONS = [ |
| 249 | + { value: 'claude-4.6-opus', label: 'Claude 4.6 Opus' }, |
| 250 | + { value: 'claude-4.5-opus', label: 'Claude 4.5 Opus' }, |
| 251 | + { value: 'claude-4.5-sonnet', label: 'Claude 4.5 Sonnet' }, |
| 252 | + { value: 'claude-4.5-haiku', label: 'Claude 4.5 Haiku' }, |
| 253 | + { value: 'gpt-5.2-codex', label: 'GPT 5.2 Codex' }, |
| 254 | + { value: 'gpt-5.2-pro', label: 'GPT 5.2 Pro' }, |
| 255 | + { value: 'gemini-3-pro', label: 'Gemini 3 Pro' }, |
| 256 | +] as const |
| 257 | + |
| 258 | +/** |
| 259 | + * Threshold for considering input "near top" of viewport (in pixels) |
| 260 | + */ |
| 261 | +export const NEAR_TOP_THRESHOLD = 300 |
| 262 | + |
| 263 | +/** |
| 264 | + * Scroll tolerance for mention menu positioning (in pixels) |
| 265 | + */ |
| 266 | +export const SCROLL_TOLERANCE = 8 |
| 267 | + |
| 268 | +/** |
| 269 | + * Shared CSS classes for menu state text (loading, empty states) |
| 270 | + */ |
| 271 | +export const MENU_STATE_TEXT_CLASSES = 'px-[8px] py-[8px] text-[12px] text-[var(--text-muted)]' |
| 272 | + |
| 273 | +/** |
| 274 | + * Calculates the next index for circular navigation (wraps around at bounds) |
| 275 | + */ |
| 276 | +export function getNextIndex(current: number, direction: 'up' | 'down', maxIndex: number): number { |
| 277 | + if (direction === 'down') { |
| 278 | + return current >= maxIndex ? 0 : current + 1 |
| 279 | + } |
| 280 | + return current <= 0 ? maxIndex : current - 1 |
| 281 | +} |
0 commit comments