@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,204 @@
1
+ /**
2
+ * ChatInput 组件样式
3
+ */
4
+
5
+ .chat-input {
6
+ padding: 12px;
7
+ }
8
+
9
+ .chat-input.message-variant {
10
+ padding: 0;
11
+ margin-bottom: 16px;
12
+ }
13
+
14
+ .input-container {
15
+ display: flex;
16
+ flex-direction: column;
17
+ background: var(--chat-input-bg, #2d2d2d);
18
+ border: 1px solid var(--chat-border, #444);
19
+ border-radius: 12px;
20
+ padding: 12px;
21
+ transition: border-color 0.15s;
22
+ }
23
+
24
+ .input-container.focused {
25
+ border-color: rgba(255, 255, 255, 0.2);
26
+ }
27
+
28
+ .input-container.drag-over {
29
+ border-color: var(--chat-primary, #2563eb);
30
+ background: rgba(37, 99, 235, 0.1);
31
+ }
32
+
33
+ /* 图片预览区 */
34
+ .images-preview {
35
+ display: flex;
36
+ flex-wrap: wrap;
37
+ gap: 6px;
38
+ margin-bottom: 8px;
39
+ }
40
+
41
+ .image-preview-item {
42
+ position: relative;
43
+ width: 48px;
44
+ height: 48px;
45
+ border-radius: 6px;
46
+ overflow: hidden;
47
+ background: rgba(0, 0, 0, 0.2);
48
+ }
49
+
50
+ .image-thumbnail {
51
+ width: 100%;
52
+ height: 100%;
53
+ object-fit: cover;
54
+ cursor: pointer;
55
+ transition: opacity 0.15s;
56
+ }
57
+
58
+ .image-thumbnail:hover {
59
+ opacity: 0.8;
60
+ }
61
+
62
+ .image-remove-btn {
63
+ position: absolute;
64
+ top: 2px;
65
+ right: 2px;
66
+ width: 16px;
67
+ height: 16px;
68
+ display: flex;
69
+ align-items: center;
70
+ justify-content: center;
71
+ background: rgba(0, 0, 0, 0.5);
72
+ border: none;
73
+ border-radius: 50%;
74
+ color: #fff;
75
+ cursor: pointer;
76
+ opacity: 0;
77
+ transition: all 0.15s;
78
+ }
79
+
80
+ .image-preview-item:hover .image-remove-btn {
81
+ opacity: 1;
82
+ }
83
+
84
+ .image-remove-btn:hover {
85
+ background: rgba(239, 68, 68, 0.9);
86
+ }
87
+
88
+ /* 隐藏的文件输入 */
89
+ .hidden-input {
90
+ display: none;
91
+ }
92
+
93
+ /* 输入框 */
94
+ .input-field-wrapper {
95
+ margin-bottom: 8px;
96
+ }
97
+
98
+ .input-field {
99
+ width: 100%;
100
+ background: transparent;
101
+ border: none;
102
+ padding: 0;
103
+ padding-right: 8px;
104
+ color: var(--chat-text, #ccc);
105
+ font-size: 14px;
106
+ resize: none;
107
+ min-height: 24px;
108
+ max-height: 150px;
109
+ line-height: 1.5;
110
+ font-family: inherit;
111
+ overflow-y: auto;
112
+ }
113
+
114
+ .input-field:focus {
115
+ outline: none;
116
+ }
117
+
118
+ .input-field::placeholder {
119
+ color: var(--chat-text-muted, #666);
120
+ }
121
+
122
+ /* 底部控制栏 */
123
+ .input-controls {
124
+ display: flex;
125
+ align-items: center;
126
+ justify-content: space-between;
127
+ gap: 8px;
128
+ }
129
+
130
+ .input-left {
131
+ display: flex;
132
+ align-items: center;
133
+ gap: 4px;
134
+ }
135
+
136
+ /* 右侧按钮 */
137
+ .input-right {
138
+ display: flex;
139
+ align-items: center;
140
+ gap: 2px;
141
+ }
142
+
143
+ /* @ 选择器容器 */
144
+ .at-picker-wrapper {
145
+ position: relative;
146
+ }
147
+
148
+ .input-right .icon-btn {
149
+ display: flex;
150
+ align-items: center;
151
+ justify-content: center;
152
+ width: 28px;
153
+ height: 28px;
154
+ background: transparent;
155
+ border: none;
156
+ border-radius: 6px;
157
+ color: var(--chat-text-muted, #666);
158
+ cursor: pointer;
159
+ transition: all 0.15s;
160
+ }
161
+
162
+ .input-right .icon-btn:hover {
163
+ color: var(--chat-text, #ccc);
164
+ }
165
+
166
+ .toggle-btn {
167
+ display: flex;
168
+ align-items: center;
169
+ justify-content: center;
170
+ width: 28px;
171
+ height: 28px;
172
+ background: transparent;
173
+ border: none;
174
+ border-radius: 6px;
175
+ color: var(--chat-text-muted, #666);
176
+ cursor: pointer;
177
+ transition: all 0.15s;
178
+ }
179
+
180
+ .toggle-btn:hover {
181
+ color: var(--chat-text, #ccc);
182
+ }
183
+
184
+ .toggle-btn.active {
185
+ color: var(--chat-text, #fff);
186
+ }
187
+
188
+ .send-btn {
189
+ display: flex;
190
+ align-items: center;
191
+ justify-content: center;
192
+ width: 28px;
193
+ height: 28px;
194
+ background: transparent;
195
+ border: none;
196
+ border-radius: 6px;
197
+ color: var(--chat-text-muted, #666);
198
+ cursor: pointer;
199
+ transition: all 0.15s;
200
+ }
201
+
202
+ .send-btn:hover {
203
+ color: var(--chat-text, #ccc);
204
+ }
@@ -0,0 +1,506 @@
1
+ /**
2
+ * ChatInput Component
3
+ * 与 Vue 版本 ChatInput.vue 保持一致
4
+ */
5
+
6
+ import { useState, useRef, useCallback, useEffect, forwardRef, useImperativeHandle, useLayoutEffect, useMemo } from 'react';
7
+ import './ChatInput.css';
8
+ import { Icon } from '@iconify/react';
9
+ import type { ChatMode, ModelOption } from '../../types';
10
+ import { AtFilePicker } from './AtFilePicker';
11
+ import { ImagePreviewModal } from './ImagePreviewModal';
12
+ import { DropdownSelector } from './DropdownSelector';
13
+ import type { DropdownOption, GroupedOptions } from './DropdownSelector';
14
+ import { useChatInputContext } from '../../context/ChatInputContext';
15
+ import { useImageUpload } from '../../hooks/useImageUpload';
16
+ import type { ImageData } from '../../adapter';
17
+
18
+ /** ChatInput 暴露给外部的方法 */
19
+ export interface ChatInputHandle {
20
+ /** 设置输入框内容 */
21
+ setText: (text: string) => void;
22
+ /** 聚焦输入框 */
23
+ focus: () => void;
24
+ /** 清空输入框 */
25
+ clear: () => void;
26
+ /** 在光标位置插入文本(用于 @ 上下文) */
27
+ insertText: (text: string) => void;
28
+ /** 添加图片 */
29
+ addImages: (files: File[]) => void;
30
+ }
31
+
32
+ interface ChatInputProps {
33
+ /** 变体模式:input-底部输入框,message-历史消息 */
34
+ variant?: 'input' | 'message';
35
+ /** 受控值(用于历史消息编辑) */
36
+ value?: string;
37
+ isLoading?: boolean;
38
+ mode?: ChatMode;
39
+ model?: string;
40
+ models?: ModelOption[];
41
+ webSearchEnabled?: boolean;
42
+ thinkingEnabled?: boolean;
43
+ onSend?: (text: string, images?: ImageData[]) => void;
44
+ onCancel?: () => void;
45
+ onAtContext?: () => void;
46
+ onModeChange?: (mode: ChatMode) => void;
47
+ onModelChange?: (model: string) => void;
48
+ onWebSearchChange?: (enabled: boolean) => void;
49
+ onThinkingChange?: (enabled: boolean) => void;
50
+ }
51
+
52
+ /** 模式配置 */
53
+ const MODE_OPTIONS: DropdownOption[] = [
54
+ { value: 'agent', label: 'Agent', icon: 'lucide:zap' },
55
+ { value: 'ask', label: 'Ask', icon: 'lucide:message-circle' },
56
+ ];
57
+
58
+ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
59
+ (
60
+ {
61
+ variant = 'input',
62
+ value = '',
63
+ isLoading = false,
64
+ mode = 'agent',
65
+ model = '',
66
+ models = [],
67
+ webSearchEnabled = false,
68
+ thinkingEnabled = false,
69
+ onSend,
70
+ onCancel,
71
+ onModeChange,
72
+ onModelChange,
73
+ onWebSearchChange,
74
+ onThinkingChange,
75
+ },
76
+ ref
77
+ ) => {
78
+ const isMessageVariant = variant === 'message';
79
+
80
+ const inputContext = useChatInputContext();
81
+ const adapter = inputContext?.adapter;
82
+ // cwd 已解耦,不再通过 context 传递
83
+
84
+ const [inputText, setInputText] = useState(value);
85
+ const [isFocused, setIsFocused] = useState(false);
86
+
87
+ // 图片上传(使用 hook)
88
+ const imageUpload = useImageUpload();
89
+ const imageInputRef = useRef<HTMLInputElement>(null);
90
+
91
+ const inputRef = useRef<HTMLTextAreaElement>(null);
92
+ const containerRef = useRef<HTMLDivElement>(null);
93
+ const atSelectorRef = useRef<HTMLDivElement>(null);
94
+
95
+ // @ 文件选择面板
96
+ const [atPickerVisible, setAtPickerVisible] = useState(false);
97
+ const replaceRangeRef = useRef<{ start: number; end: number } | null>(null);
98
+ const pendingCaretRef = useRef<number | null>(null);
99
+ const pendingFocusRef = useRef(false);
100
+ const prevValueRef = useRef(value);
101
+
102
+ // 触发文件选择
103
+ const triggerImageUpload = useCallback(() => {
104
+ imageInputRef.current?.click();
105
+ }, []);
106
+
107
+ // 将模型列表转换为分组格式(完全使用后端返回的 group 字段)
108
+ const groupedModelOptions = useMemo<GroupedOptions>(() => {
109
+ const groups: Record<string, Array<{ value: string; label: string; icon?: string }>> = {};
110
+
111
+ models.forEach((m) => {
112
+ // 完全使用后端返回的 group 字段
113
+ const groupName = m.group;
114
+
115
+ if (!groups[groupName]) {
116
+ groups[groupName] = [];
117
+ }
118
+
119
+ groups[groupName].push({
120
+ value: m.modelId,
121
+ label: m.displayName,
122
+ });
123
+ });
124
+
125
+ // 对每个分组内的选项进行排序
126
+ Object.keys(groups).forEach(groupName => {
127
+ groups[groupName].sort((a, b) => a.label.localeCompare(b.label));
128
+ });
129
+
130
+ return groups;
131
+ }, [models]);
132
+
133
+ const closeAtPicker = useCallback(() => {
134
+ setAtPickerVisible(false);
135
+ replaceRangeRef.current = null;
136
+ pendingFocusRef.current = true;
137
+ }, []);
138
+
139
+ const applyAtPath = useCallback(
140
+ (path: string) => {
141
+ const el = inputRef.current;
142
+ const current = el?.value ?? inputText;
143
+ const insert = `@${path}\n`;
144
+ const range = replaceRangeRef.current;
145
+
146
+ let nextText = current;
147
+ let nextCaret = 0;
148
+
149
+ if (range && current.slice(range.start, range.end) === '@') {
150
+ nextText = current.slice(0, range.start) + insert + current.slice(range.end);
151
+ nextCaret = range.start + insert.length;
152
+ } else if (el) {
153
+ const start = el.selectionStart ?? current.length;
154
+ const end = el.selectionEnd ?? start;
155
+ nextText = current.slice(0, start) + insert + current.slice(end);
156
+ nextCaret = start + insert.length;
157
+ } else {
158
+ nextText = current + insert;
159
+ nextCaret = nextText.length;
160
+ }
161
+
162
+ setInputText(nextText);
163
+ pendingCaretRef.current = nextCaret;
164
+ pendingFocusRef.current = true;
165
+
166
+ closeAtPicker();
167
+ },
168
+ [closeAtPicker, inputText]
169
+ );
170
+
171
+ // 切换 @ 选择器
172
+ const toggleAtPicker = useCallback(() => {
173
+ if (!adapter) return;
174
+ if (!atPickerVisible) {
175
+ replaceRangeRef.current = null;
176
+ }
177
+ setAtPickerVisible((prev) => !prev);
178
+ }, [adapter, atPickerVisible]);
179
+
180
+ // 同步外部 value
181
+ useEffect(() => {
182
+ setInputText(value);
183
+ }, [value]);
184
+
185
+ // 是否显示工具栏
186
+ const showToolbar = !isMessageVariant || isFocused;
187
+
188
+ // 占位符
189
+ const placeholder = mode === 'ask' ? '有什么问题想问我?' : '描述任务,@ 添加上下文';
190
+
191
+ // 是否有内容可发送
192
+ const hasContent = useMemo(() => {
193
+ return inputText.trim() || imageUpload.hasImages;
194
+ }, [inputText, imageUpload.hasImages]);
195
+
196
+ // 自动调整高度
197
+ const adjustTextareaHeight = useCallback(() => {
198
+ if (inputRef.current) {
199
+ inputRef.current.style.height = 'auto';
200
+ const scrollHeight = inputRef.current.scrollHeight;
201
+ inputRef.current.style.height = `${Math.min(scrollHeight, 150)}px`;
202
+ }
203
+ }, []);
204
+
205
+ // 统一在 DOM commit 后调整高度/光标
206
+ useLayoutEffect(() => {
207
+ adjustTextareaHeight();
208
+ const el = inputRef.current;
209
+ if (!el) return;
210
+
211
+ if (pendingFocusRef.current) {
212
+ el.focus();
213
+ pendingFocusRef.current = false;
214
+ }
215
+ if (pendingCaretRef.current !== null) {
216
+ const pos = pendingCaretRef.current;
217
+ pendingCaretRef.current = null;
218
+ el.setSelectionRange(pos, pos);
219
+ }
220
+ }, [inputText, adjustTextareaHeight]);
221
+
222
+ // 暴露给外部的方法
223
+ useImperativeHandle(
224
+ ref,
225
+ () => ({
226
+ setText: (text: string) => {
227
+ setInputText(text);
228
+ pendingCaretRef.current = text.length;
229
+ pendingFocusRef.current = false;
230
+ },
231
+ focus: () => {
232
+ inputRef.current?.focus();
233
+ },
234
+ clear: () => {
235
+ setInputText('');
236
+ imageUpload.clearImages();
237
+ if (inputRef.current) {
238
+ inputRef.current.style.height = 'auto';
239
+ }
240
+ pendingCaretRef.current = 0;
241
+ },
242
+ insertText: (text: string) => {
243
+ if (!text) return;
244
+ const el = inputRef.current;
245
+ const current = inputText;
246
+ if (!el) {
247
+ setInputText(current + text);
248
+ pendingCaretRef.current = (current + text).length;
249
+ return;
250
+ }
251
+ const start = el.selectionStart ?? current.length;
252
+ const end = el.selectionEnd ?? start;
253
+ const next = current.slice(0, start) + text + current.slice(end);
254
+ setInputText(next);
255
+ pendingCaretRef.current = start + text.length;
256
+ pendingFocusRef.current = true;
257
+ },
258
+ addImages: (files: File[]) => {
259
+ imageUpload.addImages(files);
260
+ },
261
+ }),
262
+ [inputText, imageUpload]
263
+ );
264
+
265
+ // 发送或取消
266
+ const handleSendOrCancel = useCallback(() => {
267
+ if (isLoading) {
268
+ onCancel?.();
269
+ return;
270
+ }
271
+
272
+ const text = inputText.trim();
273
+ if (!text && !imageUpload.hasImages) return;
274
+
275
+ // 获取图片数据
276
+ const images = imageUpload.imageData.length > 0 ? imageUpload.imageData : undefined;
277
+
278
+ onSend?.(text, images);
279
+
280
+ if (!isMessageVariant) {
281
+ setInputText('');
282
+ imageUpload.clearImages();
283
+ if (inputRef.current) {
284
+ inputRef.current.style.height = 'auto';
285
+ }
286
+ pendingCaretRef.current = 0;
287
+ pendingFocusRef.current = true;
288
+ } else {
289
+ setIsFocused(false);
290
+ }
291
+ }, [isLoading, inputText, imageUpload, isMessageVariant, onCancel, onSend]);
292
+
293
+ // 处理键盘事件
294
+ const handleKeyDown = useCallback(
295
+ (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
296
+ if (e.key === 'Enter' && !e.shiftKey) {
297
+ e.preventDefault();
298
+ handleSendOrCancel();
299
+ }
300
+ },
301
+ [handleSendOrCancel]
302
+ );
303
+
304
+ // 点击外部关闭菜单
305
+ useEffect(() => {
306
+ const handleClickOutside = (event: MouseEvent) => {
307
+ const target = event.target as HTMLElement;
308
+ // 关闭 @ 选择器
309
+ if (!target.closest('.at-picker-wrapper')) {
310
+ setAtPickerVisible(false);
311
+ }
312
+ if (isMessageVariant && containerRef.current && !containerRef.current.contains(target)) {
313
+ setIsFocused(false);
314
+ }
315
+ };
316
+
317
+ document.addEventListener('click', handleClickOutside);
318
+ return () => document.removeEventListener('click', handleClickOutside);
319
+ }, [isMessageVariant]);
320
+
321
+ return (
322
+ <div
323
+ className={`chat-input${isMessageVariant ? ' message-variant' : ''}`.trim()}
324
+ onDragOver={imageUpload.handleDragOver}
325
+ onDragLeave={imageUpload.handleDragLeave}
326
+ onDrop={imageUpload.handleDrop}
327
+ >
328
+ <div
329
+ ref={containerRef}
330
+ className={`input-container${isFocused ? ' focused' : ''}${imageUpload.isDragOver ? ' drag-over' : ''}`.trim()}
331
+ >
332
+ {/* 图片预览区 */}
333
+ {imageUpload.hasImages && (
334
+ <div className="images-preview">
335
+ {imageUpload.images.map((img, i) => (
336
+ <div key={i} className="image-preview-item">
337
+ <img
338
+ src={img.dataUrl}
339
+ alt="预览"
340
+ className="image-thumbnail"
341
+ onClick={() => imageUpload.openPreview(i)}
342
+ />
343
+ <button
344
+ className="image-remove-btn"
345
+ title="移除图片"
346
+ onClick={(e) => {
347
+ e.stopPropagation();
348
+ imageUpload.removeImage(i);
349
+ }}
350
+ >
351
+ <Icon icon="lucide:x" width={10} />
352
+ </button>
353
+ </div>
354
+ ))}
355
+ </div>
356
+ )}
357
+
358
+ {/* 图片预览弹窗 */}
359
+ <ImagePreviewModal
360
+ visible={imageUpload.previewVisible}
361
+ images={imageUpload.imageUrls}
362
+ initialIndex={imageUpload.previewIndex}
363
+ onClose={imageUpload.closePreview}
364
+ onIndexChange={imageUpload.setPreviewIndex}
365
+ />
366
+
367
+ {/* 输入框 */}
368
+ <div className="input-field-wrapper">
369
+ <textarea
370
+ ref={inputRef}
371
+ value={inputText}
372
+ placeholder={placeholder}
373
+ rows={1}
374
+ className="input-field chat-scrollbar"
375
+ spellCheck={false}
376
+ autoCorrect="off"
377
+ autoComplete="off"
378
+ autoCapitalize="off"
379
+ onChange={(e) => {
380
+ const next = e.target.value;
381
+ const prev = prevValueRef.current;
382
+ prevValueRef.current = next;
383
+ setInputText(next);
384
+
385
+ // 检测是否新增输入了 '@',用事件驱动打开面板
386
+ if (adapter && next.length === prev.length + 1) {
387
+ const cursor = e.target.selectionStart ?? next.length;
388
+ const inserted = next.slice(cursor - 1, cursor);
389
+ if (inserted === '@') {
390
+ replaceRangeRef.current = { start: cursor - 1, end: cursor };
391
+ setAtPickerVisible(true);
392
+ }
393
+ }
394
+ }}
395
+ onInput={adjustTextareaHeight}
396
+ onKeyDown={handleKeyDown}
397
+ onFocus={() => setIsFocused(true)}
398
+ onPaste={imageUpload.handlePaste}
399
+ />
400
+ </div>
401
+
402
+ {/* 底部控制栏 */}
403
+ {showToolbar && (
404
+ <div className="input-controls">
405
+ {/* 左侧 */}
406
+ <div className="input-left">
407
+ {/* 模式选择 */}
408
+ <DropdownSelector
409
+ value={mode}
410
+ options={MODE_OPTIONS}
411
+ onSelect={(v) => onModeChange?.(v as ChatMode)}
412
+ />
413
+
414
+ {/* 模型选择(分组显示:原生和 OpenRouter) */}
415
+ <DropdownSelector
416
+ value={model}
417
+ groupedOptions={groupedModelOptions}
418
+ onSelect={(v) => onModelChange?.(v)}
419
+ />
420
+ </div>
421
+
422
+ {/* 右侧 */}
423
+ <div className="input-right">
424
+ <button
425
+ className={`toggle-btn${thinkingEnabled ? ' active' : ''}`}
426
+ title="深度思考"
427
+ onClick={() => onThinkingChange?.(!thinkingEnabled)}
428
+ >
429
+ <Icon icon="lucide:sparkles" width={18} />
430
+ </button>
431
+
432
+ <button
433
+ className={`toggle-btn${webSearchEnabled ? ' active' : ''}`}
434
+ title="联网搜索"
435
+ onClick={() => onWebSearchChange?.(!webSearchEnabled)}
436
+ >
437
+ <Icon icon="lucide:globe" width={18} />
438
+ </button>
439
+
440
+ {/* 图片上传按钮 */}
441
+ <button className="icon-btn" title="添加图片" onClick={triggerImageUpload}>
442
+ <Icon icon="lucide:image" width={18} />
443
+ </button>
444
+ <input
445
+ ref={imageInputRef}
446
+ type="file"
447
+ accept="image/*"
448
+ multiple
449
+ className="hidden-input"
450
+ onChange={imageUpload.handleImageSelect}
451
+ />
452
+
453
+ {/* @ 上下文选择器 */}
454
+ <div
455
+ ref={atSelectorRef}
456
+ className="at-picker-wrapper"
457
+ onClick={(e) => {
458
+ e.stopPropagation();
459
+ toggleAtPicker();
460
+ }}
461
+ >
462
+ <button className="icon-btn" title="提及上下文 (@)">
463
+ <Icon icon="lucide:at-sign" width={18} />
464
+ </button>
465
+
466
+ {/* @ 下拉菜单 */}
467
+ {adapter && (
468
+ <AtFilePicker
469
+ visible={atPickerVisible}
470
+ adapter={adapter}
471
+ // initialDir 已移除,cwd 已解耦
472
+ anchorEl={atSelectorRef.current}
473
+ onClose={closeAtPicker}
474
+ onSelect={applyAtPath}
475
+ />
476
+ )}
477
+ </div>
478
+
479
+ {hasContent || isLoading ? (
480
+ <button
481
+ className={`send-btn${isLoading ? ' loading' : ''}`}
482
+ title={isLoading ? '停止' : isMessageVariant ? '重新发送' : '发送'}
483
+ onClick={handleSendOrCancel}
484
+ >
485
+ {isLoading ? (
486
+ <Icon icon="streamline-flex:button-pause-circle-remix" width={18} />
487
+ ) : (
488
+ <Icon icon="streamline-flex:mail-send-email-message-circle-remix" width={18} />
489
+ )}
490
+ </button>
491
+ ) : (
492
+ <button className="icon-btn" title="语音输入">
493
+ <Icon icon="lucide:mic" width={18} />
494
+ </button>
495
+ )}
496
+ </div>
497
+ </div>
498
+ )}
499
+ </div>
500
+ </div>
501
+ );
502
+ }
503
+ );
504
+
505
+ // 添加 displayName 以便于调试
506
+ ChatInput.displayName = 'ChatInput';