@huyooo/ai-chat-frontend-react 0.1.6 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/README.md +368 -0
  2. package/dist/index.css +2575 -0
  3. package/dist/index.css.map +1 -0
  4. package/dist/index.d.ts +378 -135
  5. package/dist/index.js +3956 -1042
  6. package/dist/index.js.map +1 -1
  7. package/dist/style.css +48 -987
  8. package/package.json +7 -4
  9. package/src/adapter.ts +10 -70
  10. package/src/components/ChatPanel.tsx +373 -117
  11. package/src/components/common/ConfirmDialog.css +136 -0
  12. package/src/components/common/ConfirmDialog.tsx +91 -0
  13. package/src/components/common/CopyButton.css +22 -0
  14. package/src/components/common/CopyButton.tsx +46 -0
  15. package/src/components/common/IndexingSettings.css +207 -0
  16. package/src/components/common/IndexingSettings.tsx +398 -0
  17. package/src/components/common/SettingsPanel.css +256 -0
  18. package/src/components/common/SettingsPanel.tsx +120 -0
  19. package/src/components/common/Toast.css +50 -0
  20. package/src/components/common/Toast.tsx +38 -0
  21. package/src/components/common/ToggleSwitch.css +52 -0
  22. package/src/components/common/ToggleSwitch.tsx +20 -0
  23. package/src/components/header/ChatHeader.css +285 -0
  24. package/src/components/header/ChatHeader.tsx +376 -0
  25. package/src/components/input/AtFilePicker.css +147 -0
  26. package/src/components/input/AtFilePicker.tsx +519 -0
  27. package/src/components/input/ChatInput.css +204 -0
  28. package/src/components/input/ChatInput.tsx +506 -0
  29. package/src/components/input/DropdownSelector.css +159 -0
  30. package/src/components/input/DropdownSelector.tsx +195 -0
  31. package/src/components/input/ImagePreviewModal.css +124 -0
  32. package/src/components/input/ImagePreviewModal.tsx +118 -0
  33. package/src/components/input/at-views/AtBranchView.tsx +34 -0
  34. package/src/components/input/at-views/AtBrowserView.tsx +34 -0
  35. package/src/components/input/at-views/AtChatsView.tsx +34 -0
  36. package/src/components/input/at-views/AtDocsView.tsx +34 -0
  37. package/src/components/input/at-views/AtFilesView.tsx +168 -0
  38. package/src/components/input/at-views/AtTerminalsView.tsx +34 -0
  39. package/src/components/input/at-views/AtViewStyles.css +143 -0
  40. package/src/components/input/at-views/index.ts +9 -0
  41. package/src/components/message/ContentRenderer.css +9 -0
  42. package/src/components/message/ContentRenderer.tsx +63 -0
  43. package/src/components/message/MessageBubble.css +190 -0
  44. package/src/components/message/MessageBubble.tsx +231 -0
  45. package/src/components/message/PartsRenderer.css +4 -0
  46. package/src/components/message/PartsRenderer.tsx +114 -0
  47. package/src/components/message/ToolResultRenderer.tsx +21 -0
  48. package/src/components/message/WelcomeMessage.css +221 -0
  49. package/src/components/message/WelcomeMessage.tsx +93 -0
  50. package/src/components/message/blocks/CodeBlock.tsx +60 -0
  51. package/src/components/message/blocks/TextBlock.tsx +15 -0
  52. package/src/components/message/blocks/blocks.css +141 -0
  53. package/src/components/message/blocks/index.ts +6 -0
  54. package/src/components/message/parts/CollapsibleCard.css +78 -0
  55. package/src/components/message/parts/CollapsibleCard.tsx +77 -0
  56. package/src/components/message/parts/ErrorPart.css +9 -0
  57. package/src/components/message/parts/ErrorPart.tsx +40 -0
  58. package/src/components/message/parts/ImagePart.css +50 -0
  59. package/src/components/message/parts/ImagePart.tsx +54 -0
  60. package/src/components/message/parts/SearchPart.css +44 -0
  61. package/src/components/message/parts/SearchPart.tsx +63 -0
  62. package/src/components/message/parts/TextPart.css +10 -0
  63. package/src/components/message/parts/TextPart.tsx +20 -0
  64. package/src/components/message/parts/ThinkingPart.css +9 -0
  65. package/src/components/message/parts/ThinkingPart.tsx +48 -0
  66. package/src/components/message/parts/ToolCallPart.css +220 -0
  67. package/src/components/message/parts/ToolCallPart.tsx +285 -0
  68. package/src/components/message/parts/ToolResultPart.css +68 -0
  69. package/src/components/message/parts/ToolResultPart.tsx +96 -0
  70. package/src/components/message/parts/index.ts +11 -0
  71. package/src/components/message/tool-results/DefaultToolResult.tsx +26 -0
  72. package/src/components/message/tool-results/SearchResults.tsx +69 -0
  73. package/src/components/message/tool-results/WeatherCard.tsx +63 -0
  74. package/src/components/message/tool-results/index.ts +7 -0
  75. package/src/components/message/tool-results/tool-results.css +179 -0
  76. package/src/components/message/welcome-types.ts +46 -0
  77. package/src/context/AutoRunConfigContext.tsx +13 -0
  78. package/src/context/ChatAdapterContext.tsx +8 -0
  79. package/src/context/ChatInputContext.tsx +40 -0
  80. package/src/context/RenderersContext.tsx +41 -0
  81. package/src/hooks/useChat.ts +855 -237
  82. package/src/hooks/useImageUpload.ts +253 -0
  83. package/src/index.ts +96 -39
  84. package/src/styles.css +48 -987
  85. package/src/types/index.ts +172 -103
  86. package/src/utils/fileIcon.ts +49 -0
  87. package/src/components/ChatInput.tsx +0 -368
  88. package/src/components/chat/messages/ExecutionSteps.tsx +0 -234
  89. package/src/components/chat/messages/MessageBubble.tsx +0 -130
  90. package/src/components/chat/ui/ChatHeader.tsx +0 -301
  91. package/src/components/chat/ui/WelcomeMessage.tsx +0 -107
@@ -0,0 +1,519 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2
+ import { createPortal } from 'react-dom'
3
+ import { Icon } from '@iconify/react'
4
+ import type { ChatAdapter } from '../../adapter'
5
+ import { getFileIcon, basename, dirname } from '../../utils/fileIcon'
6
+ import {
7
+ AtFilesView,
8
+ AtDocsView,
9
+ AtTerminalsView,
10
+ AtChatsView,
11
+ AtBranchView,
12
+ AtBrowserView,
13
+ } from './at-views'
14
+ import type { AtFilesViewHandle, AtPlaceholderViewHandle } from './at-views'
15
+ import './AtFilePicker.css'
16
+
17
+ export interface AtFilePickerProps {
18
+ visible: boolean
19
+ adapter: ChatAdapter
20
+ initialDir?: string
21
+ /** 锚点元素(用于定位下拉面板) */
22
+ anchorEl?: HTMLElement | null
23
+ onClose: () => void
24
+ onSelect: (path: string) => void
25
+ }
26
+
27
+ // ============ 常量 ============
28
+ const RECENT_KEY = 'ai-chat.at.recentPaths'
29
+ const MAX_RECENT = 6
30
+ const DROPDOWN_WIDTH = 332
31
+ const DROPDOWN_MIN_HEIGHT = 200 // 最小高度
32
+ const DROPDOWN_MAX_HEIGHT = 600 // 最大高度(大屏幕时充分利用)
33
+
34
+ // 分类定义
35
+ type CategoryId = 'files' | 'docs' | 'terminals' | 'chats' | 'branch' | 'browser'
36
+
37
+ interface Category {
38
+ id: CategoryId
39
+ label: string
40
+ icon: string
41
+ placeholder: string
42
+ }
43
+
44
+ const categories: Category[] = [
45
+ { id: 'files', label: '文件和文件夹', icon: 'lucide:folder-open', placeholder: '搜索文件和文件夹...' },
46
+ { id: 'docs', label: '文档', icon: 'lucide:book-open', placeholder: '搜索文档...' },
47
+ { id: 'terminals', label: '终端', icon: 'lucide:terminal', placeholder: '搜索终端...' },
48
+ { id: 'chats', label: '历史对话', icon: 'lucide:message-square', placeholder: '搜索历史对话...' },
49
+ { id: 'branch', label: '分支差异', icon: 'lucide:git-branch', placeholder: '搜索分支差异...' },
50
+ { id: 'browser', label: '网页', icon: 'lucide:globe', placeholder: '搜索网页...' },
51
+ ]
52
+
53
+ type ViewType = 'categories' | CategoryId
54
+
55
+ // 视图组件的 ref 句柄类型
56
+ type ViewHandle = AtFilesViewHandle | AtPlaceholderViewHandle
57
+
58
+ export function AtFilePicker({
59
+ visible,
60
+ adapter,
61
+ initialDir,
62
+ anchorEl,
63
+ onClose,
64
+ onSelect,
65
+ }: AtFilePickerProps) {
66
+ const searchRef = useRef<HTMLInputElement>(null)
67
+ const bodyRef = useRef<HTMLDivElement>(null)
68
+ const viewRef = useRef<ViewHandle>(null)
69
+ const [query, setQuery] = useState('')
70
+ const [currentView, setCurrentView] = useState<ViewType>('categories')
71
+ const [activeKey, setActiveKey] = useState('')
72
+ const [recentList, setRecentList] = useState<string[]>([])
73
+ const [viewActiveIndex, setViewActiveIndex] = useState(-1)
74
+ const [viewItemCount, setViewItemCount] = useState(0)
75
+ const [dropdownStyle, setDropdownStyle] = useState<React.CSSProperties>({})
76
+ const [isScrolling, setIsScrolling] = useState(false)
77
+ const scrollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
78
+
79
+ // ============ 下拉面板定位 ============
80
+ const updateDropdownPosition = useCallback(() => {
81
+ if (!anchorEl) {
82
+ setDropdownStyle({})
83
+ return
84
+ }
85
+
86
+ const rect = anchorEl.getBoundingClientRect()
87
+ const viewportHeight = window.innerHeight
88
+ const viewportWidth = window.innerWidth
89
+
90
+ // 计算上方和下方的可用空间(预留 16px 边距)
91
+ const spaceAbove = rect.top - 16
92
+ const spaceBelow = viewportHeight - rect.bottom - 16
93
+
94
+ // 决定向上还是向下展开
95
+ const openUp = spaceAbove > spaceBelow
96
+
97
+ // 计算可用高度
98
+ const availableHeight = openUp ? spaceAbove : spaceBelow
99
+
100
+ // 计算最大高度:
101
+ // 1. 优先使用可用空间(确保不超出窗口)
102
+ // 2. 如果可用空间很大,限制在全局最大高度(大屏幕时充分利用但不浪费)
103
+ // 3. 同时不能超过视口高度的 80%
104
+ const viewportMaxHeight = viewportHeight * 0.8
105
+ const maxHeight = Math.min(
106
+ availableHeight, // 使用可用空间(确保不超出窗口)
107
+ DROPDOWN_MAX_HEIGHT, // 但不超过全局最大高度(大屏幕时限制在 600px)
108
+ viewportMaxHeight // 也不能超过视口的 80%
109
+ )
110
+
111
+ // 计算水平位置,确保不超出右边界
112
+ let left = rect.right - DROPDOWN_WIDTH
113
+ if (left < 8) left = 8
114
+ if (left + DROPDOWN_WIDTH > viewportWidth - 8) {
115
+ left = viewportWidth - DROPDOWN_WIDTH - 8
116
+ }
117
+
118
+ if (openUp) {
119
+ setDropdownStyle({
120
+ position: 'fixed',
121
+ left: `${left}px`,
122
+ bottom: `${viewportHeight - rect.top + 6}px`,
123
+ top: 'auto',
124
+ maxHeight: `${maxHeight}px`,
125
+ })
126
+ } else {
127
+ setDropdownStyle({
128
+ position: 'fixed',
129
+ left: `${left}px`,
130
+ top: `${rect.bottom + 6}px`,
131
+ bottom: 'auto',
132
+ maxHeight: `${maxHeight}px`,
133
+ })
134
+ }
135
+ }, [anchorEl])
136
+
137
+ // ============ 工具函数 ============
138
+ const loadRecent = useCallback(() => {
139
+ try {
140
+ const raw = localStorage.getItem(RECENT_KEY)
141
+ if (!raw) {
142
+ setRecentList([])
143
+ return
144
+ }
145
+ const list = JSON.parse(raw) as unknown
146
+ if (Array.isArray(list)) {
147
+ setRecentList(list.filter((x) => typeof x === 'string').slice(0, MAX_RECENT))
148
+ } else {
149
+ setRecentList([])
150
+ }
151
+ } catch {
152
+ setRecentList([])
153
+ }
154
+ }, [])
155
+
156
+ const pushRecent = useCallback((path: string) => {
157
+ setRecentList(prev => {
158
+ const next = [path, ...prev.filter((p) => p !== path)].slice(0, MAX_RECENT)
159
+ try {
160
+ localStorage.setItem(RECENT_KEY, JSON.stringify(next))
161
+ } catch {
162
+ // ignore
163
+ }
164
+ return next
165
+ })
166
+ }, [])
167
+
168
+ const getSearchPlaceholder = useCallback((): string => {
169
+ if (currentView === 'categories') return '添加文件、文件夹、文档...'
170
+ const cat = categories.find((c) => c.id === currentView)
171
+ return cat?.placeholder || 'Search...'
172
+ }, [currentView])
173
+
174
+ // ============ 过滤 ============
175
+ const filteredRecent = useMemo(() => {
176
+ if (currentView !== 'categories') return []
177
+ const q = query.trim().toLowerCase()
178
+ if (!q) return recentList
179
+ return recentList.filter((p) => p.toLowerCase().includes(q))
180
+ }, [currentView, query, recentList])
181
+
182
+ // ============ 视图切换 ============
183
+ const goBackToCategories = useCallback(() => {
184
+ setCurrentView('categories')
185
+ setQuery('')
186
+ setActiveKey('')
187
+ setViewActiveIndex(-1)
188
+ requestAnimationFrame(() => searchRef.current?.focus())
189
+ }, [])
190
+
191
+ const handleCategoryClick = useCallback((cat: Category) => {
192
+ setCurrentView(cat.id)
193
+ setQuery('')
194
+ setActiveKey('')
195
+ setViewActiveIndex(-1)
196
+ requestAnimationFrame(() => searchRef.current?.focus())
197
+ }, [])
198
+
199
+ // ============ 选择与关闭 ============
200
+ const handleEsc = useCallback(() => {
201
+ if (currentView !== 'categories') {
202
+ goBackToCategories()
203
+ } else {
204
+ onClose()
205
+ }
206
+ }, [currentView, goBackToCategories, onClose])
207
+
208
+ const selectPath = useCallback((path: string) => {
209
+ pushRecent(path)
210
+ onSelect(path)
211
+ }, [pushRecent, onSelect])
212
+
213
+ // ============ 滚动处理 ============
214
+ // 处理滚动事件
215
+ const handleScroll = useCallback(() => {
216
+ // 标记正在滚动
217
+ setIsScrolling(true)
218
+
219
+ // 清除之前的定时器
220
+ if (scrollTimerRef.current) {
221
+ clearTimeout(scrollTimerRef.current)
222
+ }
223
+
224
+ // 滚动停止后 150ms 才允许鼠标事件(确保滚动完全停止)
225
+ scrollTimerRef.current = setTimeout(() => {
226
+ setIsScrolling(false)
227
+ scrollTimerRef.current = null
228
+ }, 150)
229
+ }, [])
230
+
231
+ // ============ 键盘导航 ============
232
+ // 包装的 setActiveKey,检查滚动状态
233
+ const handleSetActiveKey = useCallback((key: string) => {
234
+ // 如果正在滚动,完全忽略鼠标悬停事件
235
+ // 因为滚动时鼠标位置不变,但元素在移动,会不断触发 mouseenter
236
+ if (isScrolling) return
237
+ setActiveKey(key)
238
+ }, [isScrolling])
239
+
240
+ // 包装的 setViewActiveIndex,检查滚动状态
241
+ const handleSetViewActiveIndex = useCallback((index: number) => {
242
+ // 如果正在滚动,完全忽略鼠标悬停事件
243
+ // 因为滚动时鼠标位置不变,但元素在移动,会不断触发 mouseenter
244
+ if (isScrolling) return
245
+ setViewActiveIndex(index)
246
+ }, [isScrolling])
247
+
248
+ // 滚动到选中的元素
249
+ const scrollToActive = useCallback(() => {
250
+ requestAnimationFrame(() => {
251
+ if (!bodyRef.current) return
252
+
253
+ let activeElement: HTMLElement | null = null
254
+
255
+ if (currentView === 'categories') {
256
+ // 分类视图:找到对应 activeKey 的按钮
257
+ if (activeKey.startsWith('recent:')) {
258
+ const idx = Number(activeKey.split(':')[1] || -1)
259
+ // 在最近文件列表中查找
260
+ const recentSection = bodyRef.current.querySelector('.at-picker-recent')
261
+ if (recentSection) {
262
+ const buttons = recentSection.querySelectorAll('.at-picker-item')
263
+ if (idx >= 0 && idx < buttons.length) {
264
+ activeElement = buttons[idx] as HTMLElement
265
+ }
266
+ }
267
+ } else if (activeKey.startsWith('cat:')) {
268
+ const idx = Number(activeKey.split(':')[1] || -1)
269
+ // 在分类列表中查找
270
+ const categorySection = bodyRef.current.querySelector('.at-picker-section:not(.at-picker-recent)')
271
+ if (categorySection) {
272
+ const buttons = categorySection.querySelectorAll('.at-picker-item')
273
+ if (idx >= 0 && idx < buttons.length) {
274
+ activeElement = buttons[idx] as HTMLElement
275
+ }
276
+ }
277
+ }
278
+ } else {
279
+ // 子视图:找到对应 activeIndex 的元素
280
+ const activeItems = bodyRef.current.querySelectorAll('.at-view-item.active')
281
+ if (activeItems.length > 0) {
282
+ activeElement = activeItems[0] as HTMLElement
283
+ }
284
+ }
285
+
286
+ // 滚动到视窗内
287
+ if (activeElement) {
288
+ // 使用 requestAnimationFrame 确保 DOM 已更新
289
+ requestAnimationFrame(() => {
290
+ activeElement?.scrollIntoView({
291
+ behavior: 'smooth',
292
+ block: 'nearest',
293
+ inline: 'nearest',
294
+ })
295
+ // 滚动事件会通过 handleScroll 自动处理 isScrolling 状态
296
+ })
297
+ }
298
+ })
299
+ }, [currentView, activeKey, filteredRecent.length])
300
+
301
+ const move = useCallback((delta: number) => {
302
+ if (currentView === 'categories') {
303
+ const recentCount = filteredRecent.length
304
+ const catCount = categories.length
305
+ const total = recentCount + catCount
306
+ if (total === 0) return
307
+
308
+ let pos = -1
309
+ if (activeKey.startsWith('recent:')) {
310
+ pos = Number(activeKey.split(':')[1] || -1)
311
+ } else if (activeKey.startsWith('cat:')) {
312
+ pos = recentCount + Number(activeKey.split(':')[1] || -1)
313
+ }
314
+
315
+ const next = pos < 0 ? 0 : (pos + delta + total) % total
316
+ // 直接更新 activeKey,确保状态立即更新
317
+ if (next < recentCount) {
318
+ setActiveKey(`recent:${next}`)
319
+ } else {
320
+ setActiveKey(`cat:${next - recentCount}`)
321
+ }
322
+ // 立即滚动,滚动事件会自动处理 isScrolling 状态
323
+ scrollToActive()
324
+ } else {
325
+ const total = viewItemCount
326
+ if (total === 0) return
327
+
328
+ const current = viewActiveIndex
329
+ const next = current < 0 ? 0 : (current + delta + total) % total
330
+ // 直接更新 viewActiveIndex,确保状态立即更新
331
+ setViewActiveIndex(next)
332
+ // 立即滚动,滚动事件会自动处理 isScrolling 状态
333
+ scrollToActive()
334
+ }
335
+ }, [currentView, filteredRecent.length, activeKey, viewItemCount, viewActiveIndex, scrollToActive])
336
+
337
+ const confirmActive = useCallback(() => {
338
+ if (currentView === 'categories') {
339
+ if (activeKey.startsWith('recent:')) {
340
+ const idx = Number(activeKey.split(':')[1])
341
+ const p = filteredRecent[idx]
342
+ if (p) selectPath(p)
343
+ } else if (activeKey.startsWith('cat:')) {
344
+ const idx = Number(activeKey.split(':')[1])
345
+ const cat = categories[idx]
346
+ if (cat) handleCategoryClick(cat)
347
+ }
348
+ } else {
349
+ viewRef.current?.confirmActive()
350
+ }
351
+ }, [currentView, activeKey, filteredRecent, selectPath, handleCategoryClick])
352
+
353
+ // ============ 键盘事件处理 ============
354
+ const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
355
+ if (e.key === 'ArrowDown') {
356
+ e.preventDefault()
357
+ move(1)
358
+ } else if (e.key === 'ArrowUp') {
359
+ e.preventDefault()
360
+ move(-1)
361
+ } else if (e.key === 'Enter') {
362
+ e.preventDefault()
363
+ confirmActive()
364
+ } else if (e.key === 'Escape') {
365
+ e.preventDefault()
366
+ handleEsc()
367
+ }
368
+ }, [move, confirmActive, handleEsc])
369
+
370
+ // ============ 生命周期 ============
371
+ useEffect(() => {
372
+ if (!visible) return
373
+ loadRecent()
374
+ setQuery('')
375
+ setActiveKey('')
376
+ setCurrentView('categories')
377
+ setViewActiveIndex(-1)
378
+ updateDropdownPosition()
379
+ requestAnimationFrame(() => searchRef.current?.focus())
380
+ }, [visible, loadRecent, updateDropdownPosition])
381
+
382
+ // 窗口大小变化时更新位置
383
+ useEffect(() => {
384
+ if (!visible) return
385
+ const handleResize = () => updateDropdownPosition()
386
+ window.addEventListener('resize', handleResize)
387
+ window.addEventListener('scroll', handleResize, true)
388
+ return () => {
389
+ window.removeEventListener('resize', handleResize)
390
+ window.removeEventListener('scroll', handleResize, true)
391
+ // 清理滚动定时器
392
+ if (scrollTimerRef.current) {
393
+ clearTimeout(scrollTimerRef.current)
394
+ scrollTimerRef.current = null
395
+ }
396
+ }
397
+ }, [visible, updateDropdownPosition])
398
+
399
+ // anchorEl 变化时更新位置
400
+ useEffect(() => {
401
+ if (visible) {
402
+ updateDropdownPosition()
403
+ }
404
+ }, [visible, anchorEl, updateDropdownPosition])
405
+
406
+ if (!visible) return null
407
+
408
+ // 渲染子视图组件
409
+ const renderViewComponent = () => {
410
+ const commonProps = {
411
+ query,
412
+ activeIndex: viewActiveIndex,
413
+ onSelect: selectPath,
414
+ onSetActive: handleSetViewActiveIndex,
415
+ onUpdateCount: setViewItemCount,
416
+ }
417
+
418
+ switch (currentView) {
419
+ case 'files':
420
+ return <AtFilesView ref={viewRef} adapter={adapter} initialDir={initialDir} {...commonProps} />
421
+ case 'docs':
422
+ return <AtDocsView ref={viewRef} {...commonProps} />
423
+ case 'terminals':
424
+ return <AtTerminalsView ref={viewRef} {...commonProps} />
425
+ case 'chats':
426
+ return <AtChatsView ref={viewRef} {...commonProps} />
427
+ case 'branch':
428
+ return <AtBranchView ref={viewRef} {...commonProps} />
429
+ case 'browser':
430
+ return <AtBrowserView ref={viewRef} {...commonProps} />
431
+ default:
432
+ return null
433
+ }
434
+ }
435
+
436
+ return createPortal(
437
+ <div className="at-picker-dropdown" style={dropdownStyle} onClick={(e) => e.stopPropagation()}>
438
+ {/* 头部:搜索框 */}
439
+ <div className="at-picker-header">
440
+ {currentView !== 'categories' ? (
441
+ <button className="at-picker-back" onClick={goBackToCategories}>
442
+ <Icon icon="lucide:arrow-left" width={14} />
443
+ </button>
444
+ ) : (
445
+ <Icon icon="lucide:search" width={14} className="at-picker-search-icon" />
446
+ )}
447
+ <input
448
+ ref={searchRef}
449
+ value={query}
450
+ onChange={(e) => setQuery(e.target.value)}
451
+ className="at-picker-search"
452
+ type="text"
453
+ placeholder={getSearchPlaceholder()}
454
+ spellCheck={false}
455
+ autoCorrect="off"
456
+ autoComplete="off"
457
+ autoCapitalize="off"
458
+ onKeyDown={handleKeyDown}
459
+ />
460
+ </div>
461
+
462
+ <div
463
+ ref={bodyRef}
464
+ className={`at-picker-body chat-scrollbar${isScrolling ? ' is-scrolling' : ''}`}
465
+ onScroll={handleScroll}
466
+ >
467
+ {/* 分类视图 */}
468
+ {currentView === 'categories' ? (
469
+ <>
470
+ {/* 最近文件 */}
471
+ {filteredRecent.length > 0 && (
472
+ <div className="at-picker-section at-picker-recent">
473
+ <div className="at-picker-list">
474
+ {filteredRecent.map((p, i) => (
475
+ <button
476
+ key={p}
477
+ className={`at-picker-item${activeKey === `recent:${i}` ? ' active' : ''}`}
478
+ onMouseEnter={() => handleSetActiveKey(`recent:${i}`)}
479
+ onClick={() => selectPath(p)}
480
+ >
481
+ <Icon icon={getFileIcon(p)} width={14} className="at-picker-item-icon" />
482
+ <span className="at-picker-item-name">{basename(p)}</span>
483
+ <span className="at-picker-item-path">{dirname(p)}</span>
484
+ </button>
485
+ ))}
486
+ </div>
487
+ </div>
488
+ )}
489
+
490
+ {/* 分类列表 */}
491
+ <div className="at-picker-section">
492
+ <div className="at-picker-list">
493
+ {categories.map((cat, i) => (
494
+ <button
495
+ key={cat.id}
496
+ className={`at-picker-item at-picker-category${activeKey === `cat:${i}` ? ' active' : ''}`}
497
+ onMouseEnter={() => handleSetActiveKey(`cat:${i}`)}
498
+ onClick={() => handleCategoryClick(cat)}
499
+ >
500
+ <Icon icon={cat.icon} width={14} className="at-picker-item-icon" />
501
+ <span className="at-picker-item-name">{cat.label}</span>
502
+ <Icon icon="lucide:chevron-right" width={12} className="at-picker-chevron" />
503
+ </button>
504
+ ))}
505
+ </div>
506
+ </div>
507
+ </>
508
+ ) : (
509
+ renderViewComponent()
510
+ )}
511
+ </div>
512
+
513
+ <div className="at-picker-footer">
514
+ <span className="at-picker-hint">↑↓ 导航 · Enter 选择 · Esc {currentView === 'categories' ? '关闭' : '返回'}</span>
515
+ </div>
516
+ </div>,
517
+ document.body
518
+ )
519
+ }