@huyooo/ai-chat-frontend-react 0.2.12 → 0.2.14

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 (110) hide show
  1. package/README.md +99 -84
  2. package/dist/KaTeX_AMS-Regular-CYEKBG2K.woff +0 -0
  3. package/dist/KaTeX_AMS-Regular-JKX5W2C4.ttf +0 -0
  4. package/dist/KaTeX_AMS-Regular-U6PRYMIZ.woff2 +0 -0
  5. package/dist/KaTeX_Caligraphic-Bold-5QL5CMTE.woff2 +0 -0
  6. package/dist/KaTeX_Caligraphic-Bold-WZ3QSGD3.woff +0 -0
  7. package/dist/KaTeX_Caligraphic-Bold-ZTS3R3HK.ttf +0 -0
  8. package/dist/KaTeX_Caligraphic-Regular-3LKEU76G.woff +0 -0
  9. package/dist/KaTeX_Caligraphic-Regular-A7XRTZ5Q.ttf +0 -0
  10. package/dist/KaTeX_Caligraphic-Regular-KX5MEWCF.woff2 +0 -0
  11. package/dist/KaTeX_Fraktur-Bold-2QVFK6NQ.woff2 +0 -0
  12. package/dist/KaTeX_Fraktur-Bold-T4SWXBMT.woff +0 -0
  13. package/dist/KaTeX_Fraktur-Bold-WGHVTYOR.ttf +0 -0
  14. package/dist/KaTeX_Fraktur-Regular-2PEIFJSJ.woff2 +0 -0
  15. package/dist/KaTeX_Fraktur-Regular-5U4OPH2X.ttf +0 -0
  16. package/dist/KaTeX_Fraktur-Regular-PQMHCIK6.woff +0 -0
  17. package/dist/KaTeX_Main-Bold-2GA4IZIN.woff +0 -0
  18. package/dist/KaTeX_Main-Bold-W5FBVCZM.ttf +0 -0
  19. package/dist/KaTeX_Main-Bold-YP5VVQRP.woff2 +0 -0
  20. package/dist/KaTeX_Main-BoldItalic-4P4C7HJH.woff +0 -0
  21. package/dist/KaTeX_Main-BoldItalic-N4V3DX7S.woff2 +0 -0
  22. package/dist/KaTeX_Main-BoldItalic-ODMLBJJQ.ttf +0 -0
  23. package/dist/KaTeX_Main-Italic-I43T2HSR.ttf +0 -0
  24. package/dist/KaTeX_Main-Italic-RELBIK7M.woff2 +0 -0
  25. package/dist/KaTeX_Main-Italic-SASNQFN2.woff +0 -0
  26. package/dist/KaTeX_Main-Regular-ARRPAO67.woff2 +0 -0
  27. package/dist/KaTeX_Main-Regular-P5I74A2A.woff +0 -0
  28. package/dist/KaTeX_Main-Regular-W74P5G27.ttf +0 -0
  29. package/dist/KaTeX_Math-BoldItalic-6EBV3DK5.woff +0 -0
  30. package/dist/KaTeX_Math-BoldItalic-K4WTGH3J.woff2 +0 -0
  31. package/dist/KaTeX_Math-BoldItalic-VB447A4D.ttf +0 -0
  32. package/dist/KaTeX_Math-Italic-6KGCHLFN.woff2 +0 -0
  33. package/dist/KaTeX_Math-Italic-KKK3USB2.woff +0 -0
  34. package/dist/KaTeX_Math-Italic-SON4MRCA.ttf +0 -0
  35. package/dist/KaTeX_SansSerif-Bold-RRNVJFFW.woff2 +0 -0
  36. package/dist/KaTeX_SansSerif-Bold-STQ6RXC7.ttf +0 -0
  37. package/dist/KaTeX_SansSerif-Bold-X5M5EMOD.woff +0 -0
  38. package/dist/KaTeX_SansSerif-Italic-HMPFTM52.woff2 +0 -0
  39. package/dist/KaTeX_SansSerif-Italic-PSN4QKYX.woff +0 -0
  40. package/dist/KaTeX_SansSerif-Italic-WTBAZBGY.ttf +0 -0
  41. package/dist/KaTeX_SansSerif-Regular-2TL3USAE.ttf +0 -0
  42. package/dist/KaTeX_SansSerif-Regular-OQCII6EP.woff +0 -0
  43. package/dist/KaTeX_SansSerif-Regular-XIQ62X4E.woff2 +0 -0
  44. package/dist/KaTeX_Script-Regular-72OLXYNA.ttf +0 -0
  45. package/dist/KaTeX_Script-Regular-A5IFOEBS.woff +0 -0
  46. package/dist/KaTeX_Script-Regular-APUWIHLP.woff2 +0 -0
  47. package/dist/KaTeX_Size1-Regular-4HRHTS65.woff +0 -0
  48. package/dist/KaTeX_Size1-Regular-5LRUTBFT.woff2 +0 -0
  49. package/dist/KaTeX_Size1-Regular-7K6AASVL.ttf +0 -0
  50. package/dist/KaTeX_Size2-Regular-222HN3GT.ttf +0 -0
  51. package/dist/KaTeX_Size2-Regular-K5ZHAIS6.woff +0 -0
  52. package/dist/KaTeX_Size2-Regular-LELKET5D.woff2 +0 -0
  53. package/dist/KaTeX_Size3-Regular-TLFPAHDE.woff +0 -0
  54. package/dist/KaTeX_Size3-Regular-UFCO6WCA.ttf +0 -0
  55. package/dist/KaTeX_Size3-Regular-WQRQ47UD.woff2 +0 -0
  56. package/dist/KaTeX_Size4-Regular-7PGNVPQK.ttf +0 -0
  57. package/dist/KaTeX_Size4-Regular-CDMV7U5C.woff2 +0 -0
  58. package/dist/KaTeX_Size4-Regular-PKMWZHNC.woff +0 -0
  59. package/dist/KaTeX_Typewriter-Regular-3F5K6SQ6.ttf +0 -0
  60. package/dist/KaTeX_Typewriter-Regular-MJMFSK64.woff +0 -0
  61. package/dist/KaTeX_Typewriter-Regular-VBYJ4NRC.woff2 +0 -0
  62. package/dist/index.css +2156 -603
  63. package/dist/index.css.map +1 -1
  64. package/dist/index.d.ts +126 -92
  65. package/dist/index.js +1605 -976
  66. package/dist/index.js.map +1 -1
  67. package/dist/style.css +130 -0
  68. package/package.json +3 -3
  69. package/src/components/ChatPanel.tsx +82 -19
  70. package/src/components/common/SettingsPanel.css +81 -0
  71. package/src/components/common/SettingsPanel.tsx +96 -1
  72. package/src/components/input/ChatInput.css +0 -1
  73. package/src/components/input/ChatInput.tsx +48 -26
  74. package/src/components/input/DropdownSelector.css +66 -0
  75. package/src/components/input/DropdownSelector.tsx +157 -19
  76. package/src/components/message/MessageBubble.css +5 -2
  77. package/src/components/message/MessageBubble.tsx +44 -35
  78. package/src/components/message/PartsRenderer.css +8 -0
  79. package/src/components/message/PartsRenderer.tsx +137 -83
  80. package/src/components/message/parts/CollapsibleCard.css +4 -2
  81. package/src/components/message/parts/CollapsibleCard.tsx +4 -1
  82. package/src/components/message/parts/ImagePart.css +0 -1
  83. package/src/components/message/parts/TextPart.css +574 -5
  84. package/src/components/message/parts/TextPart.tsx +201 -8
  85. package/src/components/message/parts/ToolCallPart.css +139 -115
  86. package/src/components/message/parts/ToolCallPart.tsx +138 -134
  87. package/src/components/message/parts/ToolResultPart.css +0 -1
  88. package/src/components/message/parts/index.ts +3 -1
  89. package/src/components/message/parts/visual-predicate.ts +43 -0
  90. package/src/components/message/parts/visual-render.ts +19 -0
  91. package/src/components/message/parts/visual.ts +12 -0
  92. package/src/context/RenderersContext.tsx +19 -25
  93. package/src/hooks/useChat.ts +567 -79
  94. package/src/hooks/useImageUpload.ts +104 -12
  95. package/src/hooks/useVoiceInput.ts +17 -0
  96. package/src/index.ts +19 -16
  97. package/src/styles.css +130 -0
  98. package/src/types/index.ts +52 -68
  99. package/src/components/message/ContentRenderer.tsx +0 -63
  100. package/src/components/message/ToolResultRenderer.tsx +0 -21
  101. package/src/components/message/blocks/CodeBlock.tsx +0 -60
  102. package/src/components/message/blocks/TextBlock.tsx +0 -15
  103. package/src/components/message/blocks/blocks.css +0 -141
  104. package/src/components/message/blocks/index.ts +0 -6
  105. package/src/components/message/parts/ToolResultPart.tsx +0 -96
  106. package/src/components/message/tool-results/DefaultToolResult.tsx +0 -26
  107. package/src/components/message/tool-results/SearchResults.tsx +0 -69
  108. package/src/components/message/tool-results/WeatherCard.tsx +0 -63
  109. package/src/components/message/tool-results/index.ts +0 -7
  110. package/src/components/message/tool-results/tool-results.css +0 -181
@@ -10,7 +10,7 @@ import type { ChatMode, ModelOption } from '../../types';
10
10
  import { AtFilePicker } from './AtFilePicker';
11
11
  import { ImagePreviewModal } from './ImagePreviewModal';
12
12
  import { DropdownSelector } from './DropdownSelector';
13
- import type { DropdownOption, GroupedOptions } from './DropdownSelector';
13
+ import type { DropdownOption } from './DropdownSelector';
14
14
  import { useChatInputContext } from '../../context/ChatInputContext';
15
15
  import { useImageUpload } from '../../hooks/useImageUpload';
16
16
  import { useVoiceToTextInput } from '../../hooks/useVoiceToTextInput';
@@ -35,9 +35,12 @@ interface ChatInputProps {
35
35
  variant?: 'input' | 'message';
36
36
  /** 受控值(用于历史消息编辑) */
37
37
  value?: string;
38
+ /** 图片数据(用于历史消息编辑) */
39
+ images?: string[];
38
40
  isLoading?: boolean;
39
41
  mode?: ChatMode;
40
42
  model?: string;
43
+ /** 模型列表(tooltip 由后端下发,前端仅透传渲染) */
41
44
  models?: ModelOption[];
42
45
  webSearchEnabled?: boolean;
43
46
  thinkingEnabled?: boolean;
@@ -61,6 +64,7 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
61
64
  {
62
65
  variant = 'input',
63
66
  value = '',
67
+ images = [],
64
68
  isLoading = false,
65
69
  mode = 'agent',
66
70
  model = '',
@@ -105,31 +109,23 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
105
109
  imageInputRef.current?.click();
106
110
  }, []);
107
111
 
108
- // 将模型列表转换为分组格式(完全使用后端返回的 group 字段)
109
- const groupedModelOptions = useMemo<GroupedOptions>(() => {
110
- const groups: Record<string, Array<{ value: string; label: string; icon?: string }>> = {};
111
-
112
- models.forEach((m) => {
113
- // 完全使用后端返回的 group 字段
114
- const groupName = m.group;
115
-
116
- if (!groups[groupName]) {
117
- groups[groupName] = [];
118
- }
119
-
120
- groups[groupName].push({
112
+ // 模型选项
113
+ // 模型选项:tooltip/能力由后端(ai-chat-core)统一提供,前端只负责渲染
114
+ const modelOptions = useMemo<DropdownOption[]>(
115
+ () =>
116
+ models.map((m) => ({
121
117
  value: m.modelId,
122
118
  label: m.displayName,
123
- });
124
- });
125
-
126
- // 对每个分组内的选项进行排序
127
- Object.keys(groups).forEach(groupName => {
128
- groups[groupName].sort((a, b) => a.label.localeCompare(b.label));
129
- });
130
-
131
- return groups;
132
- }, [models]);
119
+ tooltip: m.tooltip,
120
+ })),
121
+ [models]
122
+ );
123
+
124
+ // 当前模型是否支持深度思考
125
+ const currentModelSupportsThinking = useMemo(() => {
126
+ const currentModel = models.find(m => m.modelId === model);
127
+ return currentModel?.supportsThinking ?? false;
128
+ }, [models, model]);
133
129
 
134
130
  const closeAtPicker = useCallback(() => {
135
131
  setAtPickerVisible(false);
@@ -183,6 +179,18 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
183
179
  setInputText(value);
184
180
  }, [value]);
185
181
 
182
+ // 同步外部 images(用于历史消息编辑)
183
+ // 使用 JSON.stringify 比较,避免空数组引用变化导致无限循环
184
+ const imagesKey = JSON.stringify(images);
185
+ useEffect(() => {
186
+ if (images?.length) {
187
+ imageUpload.initImages(images);
188
+ } else {
189
+ imageUpload.clearImages();
190
+ }
191
+ // eslint-disable-next-line react-hooks/exhaustive-deps
192
+ }, [imagesKey]);
193
+
186
194
  // 是否显示工具栏
187
195
  const showToolbar = !isMessageVariant || isFocused;
188
196
 
@@ -252,6 +260,14 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
252
260
  clear: () => {
253
261
  setInputText('');
254
262
  imageUpload.clearImages();
263
+ // 重置语音输入状态
264
+ if (voiceInput.status !== 'idle') {
265
+ voiceInput.cancel();
266
+ }
267
+ // 重置 @ 选择器
268
+ setAtPickerVisible(false);
269
+ replaceRangeRef.current = null;
270
+ // 重置输入框高度
255
271
  if (inputRef.current) {
256
272
  inputRef.current.style.height = 'auto';
257
273
  }
@@ -434,13 +450,15 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
434
450
  {/* 模型选择(分组显示:原生和 OpenRouter) */}
435
451
  <DropdownSelector
436
452
  value={model}
437
- groupedOptions={groupedModelOptions}
453
+ options={modelOptions}
438
454
  onSelect={(v) => onModelChange?.(v)}
439
455
  />
440
456
  </div>
441
457
 
442
458
  {/* 右侧 */}
443
459
  <div className="input-right">
460
+ {/* 深度思考(ask 模式隐藏,仅支持的模型显示) */}
461
+ {mode !== 'ask' && currentModelSupportsThinking && (
444
462
  <button
445
463
  className={`toggle-btn${thinkingEnabled ? ' active' : ''}`}
446
464
  title="深度思考"
@@ -448,7 +466,10 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
448
466
  >
449
467
  <Icon icon="lucide:sparkles" width={18} />
450
468
  </button>
469
+ )}
451
470
 
471
+ {/* 联网搜索(ask 模式隐藏) */}
472
+ {mode !== 'ask' && (
452
473
  <button
453
474
  className={`toggle-btn${webSearchEnabled ? ' active' : ''}`}
454
475
  title="联网搜索"
@@ -456,6 +477,7 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
456
477
  >
457
478
  <Icon icon="lucide:globe" width={18} />
458
479
  </button>
480
+ )}
459
481
 
460
482
  {/* 图片上传按钮 */}
461
483
  <button className="icon-btn" title="添加图片" onClick={triggerImageUpload}>
@@ -537,7 +559,7 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
537
559
  {isLoading ? (
538
560
  <Icon icon="streamline-flex:button-pause-circle-remix" width={18} />
539
561
  ) : (
540
- <Icon icon="streamline-flex:mail-send-email-message-circle-remix" width={18} />
562
+ <Icon icon="uil:message" width={18} />
541
563
  )}
542
564
  </button>
543
565
  </div>
@@ -163,3 +163,69 @@
163
163
  .dropdown-selector .provider-badge.native {
164
164
  color: var(--chat-text-muted, #999);
165
165
  }
166
+
167
+ /* 选项包装器 */
168
+ .dropdown-selector .dropdown-item-wrapper {
169
+ position: relative;
170
+ }
171
+
172
+ /* 模型特性提示框(组件内渲染,但使用 fixed 定位) */
173
+ .ai-chat-model-tooltip {
174
+ /* 比之前更窄一点,避免“太宽” */
175
+ min-width: 160px;
176
+ max-width: 240px;
177
+ width: max-content;
178
+ padding: 12px;
179
+ background: var(--chat-dropdown-bg, #252526);
180
+ border: 1px solid rgba(255, 255, 255, 0.15);
181
+ border-radius: 8px;
182
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
183
+ pointer-events: none;
184
+ }
185
+
186
+ .ai-chat-model-tooltip__section {
187
+ margin-bottom: 10px;
188
+ }
189
+
190
+ .ai-chat-model-tooltip__section:last-child {
191
+ margin-bottom: 0;
192
+ }
193
+
194
+ .ai-chat-model-tooltip__title {
195
+ font-size: 11px;
196
+ font-weight: 500;
197
+ color: var(--chat-text-muted, #888);
198
+ margin-bottom: 6px;
199
+ text-transform: uppercase;
200
+ letter-spacing: 0.5px;
201
+ }
202
+
203
+ .ai-chat-model-tooltip__features {
204
+ display: flex;
205
+ flex-direction: column;
206
+ gap: 4px;
207
+ }
208
+
209
+ .ai-chat-model-tooltip__feature {
210
+ display: flex;
211
+ align-items: center;
212
+ gap: 6px;
213
+ font-size: 13px;
214
+ color: var(--chat-text, #ccc);
215
+ }
216
+
217
+ .ai-chat-model-tooltip__check {
218
+ color: #4ade80;
219
+ flex-shrink: 0;
220
+ }
221
+
222
+ .ai-chat-model-tooltip__cost {
223
+ font-size: 13px;
224
+ color: var(--chat-text, #ccc);
225
+ }
226
+
227
+ .ai-chat-model-tooltip__description {
228
+ font-size: 12px;
229
+ color: var(--chat-text-muted, #999);
230
+ line-height: 1.5;
231
+ }
@@ -4,7 +4,7 @@
4
4
  * 前端只负责渲染,分组数据由后端提供
5
5
  */
6
6
 
7
- import { useState, useRef, useCallback, useEffect, useMemo } from 'react';
7
+ import { useState, useRef, useCallback, useEffect, useMemo, useLayoutEffect } from 'react';
8
8
  import { Icon } from '@iconify/react';
9
9
  import './DropdownSelector.css';
10
10
 
@@ -15,6 +15,15 @@ export interface DropdownOption {
15
15
  icon?: string;
16
16
  /** 分组名称(由后端决定,前端只负责渲染) */
17
17
  group?: string;
18
+ /** 模型特性信息(用于 hover 提示) */
19
+ tooltip?: {
20
+ /** 可用功能列表 */
21
+ features?: string[];
22
+ /** 开销信息(数组,分行显示) */
23
+ cost?: string[];
24
+ /** 其他描述信息 */
25
+ description?: string;
26
+ };
18
27
  }
19
28
 
20
29
  /** 分组后的选项(由后端提供) */
@@ -49,8 +58,12 @@ export function DropdownSelector({
49
58
  align = 'left',
50
59
  }: DropdownSelectorProps) {
51
60
  const selectorRef = useRef<HTMLDivElement>(null);
61
+ const tooltipRef = useRef<HTMLDivElement>(null);
62
+ const hoveredItemRef = useRef<HTMLElement | null>(null);
52
63
  const [menuOpen, setMenuOpen] = useState(false);
53
64
  const [dropdownDirection, setDropdownDirection] = useState<'up' | 'down'>('up');
65
+ const [hoveredOption, setHoveredOption] = useState<DropdownOption | null>(null);
66
+ const [tooltipPosition, setTooltipPosition] = useState({ top: 0, left: 0 });
54
67
 
55
68
  // 检查是否有选项
56
69
  const hasOptions = useMemo(() => {
@@ -115,6 +128,70 @@ export function DropdownSelector({
115
128
  [onSelect]
116
129
  );
117
130
 
131
+ /**
132
+ * 处理选项 hover 事件
133
+ */
134
+ const handleItemHover = useCallback((event: React.MouseEvent<HTMLDivElement>, opt: DropdownOption) => {
135
+ const target = event.currentTarget as HTMLElement;
136
+ hoveredItemRef.current = target;
137
+ setHoveredOption(opt);
138
+
139
+ if (!opt.tooltip) {
140
+ setTooltipPosition({ top: 0, left: 0 });
141
+ return;
142
+ }
143
+
144
+ if (!target) return;
145
+
146
+ const rect = target.getBoundingClientRect();
147
+ // 提示框初始位置:右侧,距离 8px
148
+ setTooltipPosition({
149
+ top: rect.top,
150
+ left: rect.right + 8,
151
+ });
152
+ }, []);
153
+
154
+ /**
155
+ * 当提示框显示后,调整位置(避免超出视口)
156
+ * 使用 requestAnimationFrame 确保 tooltip 已完成渲染
157
+ */
158
+ useLayoutEffect(() => {
159
+ if (!hoveredOption?.tooltip || !hoveredItemRef.current) return;
160
+
161
+ // 使用 rAF 确保 tooltip 已渲染完成
162
+ const rafId = requestAnimationFrame(() => {
163
+ if (!tooltipRef.current || !hoveredItemRef.current) return;
164
+
165
+ const tooltip = tooltipRef.current;
166
+ const item = hoveredItemRef.current;
167
+ const itemRect = item.getBoundingClientRect();
168
+ const tooltipRect = tooltip.getBoundingClientRect();
169
+
170
+ let top = itemRect.top;
171
+ let left = itemRect.right + 8;
172
+
173
+ // 检查是否会超出视口右侧
174
+ if (left + tooltipRect.width > window.innerWidth) {
175
+ // 显示在左侧
176
+ left = itemRect.left - tooltipRect.width - 8;
177
+ }
178
+
179
+ // 检查是否会超出视口底部
180
+ if (top + tooltipRect.height > window.innerHeight) {
181
+ top = window.innerHeight - tooltipRect.height - 8;
182
+ }
183
+
184
+ // 检查是否会超出视口顶部
185
+ if (top < 8) {
186
+ top = 8;
187
+ }
188
+
189
+ setTooltipPosition({ top, left });
190
+ });
191
+
192
+ return () => cancelAnimationFrame(rafId);
193
+ }, [hoveredOption]);
194
+
118
195
  /**
119
196
  * 点击外部关闭菜单
120
197
  */
@@ -157,17 +234,26 @@ export function DropdownSelector({
157
234
  <div key={groupName}>
158
235
  <div className="group-title">{groupName}</div>
159
236
  {groupItems.map((opt) => (
160
- <button
237
+ <div
161
238
  key={opt.value}
162
- className={`dropdown-item${value === opt.value ? ' active' : ''}`}
163
- onClick={() => selectOption(opt.value)}
239
+ className="dropdown-item-wrapper"
240
+ onMouseEnter={(e) => handleItemHover(e, opt)}
241
+ onMouseLeave={() => {
242
+ setHoveredOption(null);
243
+ hoveredItemRef.current = null;
244
+ }}
164
245
  >
165
- {opt.icon && <Icon icon={opt.icon} width={14} />}
166
- <span className="option-label">{opt.label}</span>
167
- <span className="option-right">
168
- {value === opt.value && <Icon icon="lucide:check" width={14} className="check-icon" />}
169
- </span>
170
- </button>
246
+ <button
247
+ className={`dropdown-item${value === opt.value ? ' active' : ''}`}
248
+ onClick={() => selectOption(opt.value)}
249
+ >
250
+ {opt.icon && <Icon icon={opt.icon} width={14} />}
251
+ <span className="option-label">{opt.label}</span>
252
+ <span className="option-right">
253
+ {value === opt.value && <Icon icon="lucide:check" width={14} className="check-icon" />}
254
+ </span>
255
+ </button>
256
+ </div>
171
257
  ))}
172
258
  </div>
173
259
  ))}
@@ -175,21 +261,73 @@ export function DropdownSelector({
175
261
  ) : (
176
262
  /* 扁平列表模式:无分组数据时使用 */
177
263
  sortedOptions.map((opt) => (
178
- <button
264
+ <div
179
265
  key={opt.value}
180
- className={`dropdown-item${value === opt.value ? ' active' : ''}`}
181
- onClick={() => selectOption(opt.value)}
266
+ className="dropdown-item-wrapper"
267
+ onMouseEnter={(e) => handleItemHover(e, opt)}
268
+ onMouseLeave={() => {
269
+ setHoveredOption(null);
270
+ hoveredItemRef.current = null;
271
+ }}
182
272
  >
183
- {opt.icon && <Icon icon={opt.icon} width={14} />}
184
- <span className="option-label">{opt.label}</span>
185
- <span className="option-right">
186
- {value === opt.value && <Icon icon="lucide:check" width={14} className="check-icon" />}
187
- </span>
188
- </button>
273
+ <button
274
+ className={`dropdown-item${value === opt.value ? ' active' : ''}`}
275
+ onClick={() => selectOption(opt.value)}
276
+ >
277
+ {opt.icon && <Icon icon={opt.icon} width={14} />}
278
+ <span className="option-label">{opt.label}</span>
279
+ <span className="option-right">
280
+ {value === opt.value && <Icon icon="lucide:check" width={14} className="check-icon" />}
281
+ </span>
282
+ </button>
283
+ </div>
189
284
  ))
190
285
  )}
191
286
  </div>
192
287
  )}
288
+
289
+ {/* Hover 提示:不使用 Portal,采用 fixed 定位避免 overflow 裁剪,同时样式保持组件内可控 */}
290
+ {hoveredOption?.tooltip && (
291
+ <div
292
+ ref={tooltipRef}
293
+ className="ai-chat-model-tooltip"
294
+ style={{
295
+ position: 'fixed',
296
+ top: `${tooltipPosition.top}px`,
297
+ left: `${tooltipPosition.left}px`,
298
+ zIndex: 10000,
299
+ }}
300
+ >
301
+ {hoveredOption.tooltip.features && hoveredOption.tooltip.features.length > 0 && (
302
+ <div className="ai-chat-model-tooltip__section">
303
+ <div className="ai-chat-model-tooltip__title">可用功能</div>
304
+ <div className="ai-chat-model-tooltip__features">
305
+ {hoveredOption.tooltip.features.map((feature) => (
306
+ <div key={feature} className="ai-chat-model-tooltip__feature">
307
+ <Icon icon="lucide:check" width={12} className="ai-chat-model-tooltip__check" />
308
+ <span>{feature}</span>
309
+ </div>
310
+ ))}
311
+ </div>
312
+ </div>
313
+ )}
314
+ {hoveredOption.tooltip.cost && hoveredOption.tooltip.cost.length > 0 && (
315
+ <div className="ai-chat-model-tooltip__section">
316
+ <div className="ai-chat-model-tooltip__title">开销</div>
317
+ <div className="ai-chat-model-tooltip__cost">
318
+ {hoveredOption.tooltip.cost.map((line, i) => (
319
+ <div key={i}>{line}</div>
320
+ ))}
321
+ </div>
322
+ </div>
323
+ )}
324
+ {hoveredOption.tooltip.description && (
325
+ <div className="ai-chat-model-tooltip__section">
326
+ <div className="ai-chat-model-tooltip__description">{hoveredOption.tooltip.description}</div>
327
+ </div>
328
+ )}
329
+ </div>
330
+ )}
193
331
  </div>
194
332
  );
195
333
  }
@@ -4,7 +4,6 @@
4
4
  */
5
5
 
6
6
  .message-bubble {
7
- padding: 8px 0;
8
7
  animation: fadeIn 0.2s ease;
9
8
  }
10
9
 
@@ -79,7 +78,11 @@
79
78
  background: var(--chat-muted, #2a2a2a);
80
79
  border-radius: 8px;
81
80
  overflow: hidden;
82
- margin: 8px 0;
81
+ }
82
+
83
+ /* 当上方有内容时,添加间距 */
84
+ .loading-indicator.has-content-above {
85
+ margin-top: 8px;
83
86
  }
84
87
 
85
88
  .loading-text {
@@ -37,6 +37,8 @@ interface MessageBubbleProps {
37
37
  stepsExpandedType?: 'open' | 'close' | 'auto'
38
38
  /** 工具调用相关 - 通过 props 传递 */
39
39
  adapter?: ChatAdapter
40
+ /** 取消工具调用(通常会中止当前请求/流式输出) */
41
+ onCancelToolCall?: (toolCallId: string) => void
40
42
  autoRunConfig?: AutoRunConfig
41
43
  onSaveConfig?: (config: AutoRunConfig) => Promise<void>
42
44
  }
@@ -76,6 +78,7 @@ export const MessageBubble: FC<MessageBubbleProps> = ({
76
78
  onSend,
77
79
  stepsExpandedType = 'auto',
78
80
  adapter,
81
+ onCancelToolCall,
79
82
  autoRunConfig,
80
83
  onSaveConfig,
81
84
  }) => {
@@ -94,61 +97,65 @@ export const MessageBubble: FC<MessageBubbleProps> = ({
94
97
  // 是否有内容(用于显示操作按钮)
95
98
  const hasContent = useMemo(() => {
96
99
  return parts.some(p =>
97
- (p.type === 'text' && p.text) ||
98
- p.type === 'tool_result' ||
100
+ (p.type === 'text' && (p as TextPart).text) ||
99
101
  p.type === 'thinking' ||
100
- p.type === 'search'
102
+ p.type === 'search' ||
103
+ p.type === 'tool_call'
101
104
  )
102
105
  }, [parts])
103
106
 
104
107
  // loading 状态:决定显示什么类型的指示器
105
- // cursor: 闪烁光标(文本流式输出时)
106
108
  // text: 文字提示(等待状态时)
107
- // none: 不显示
108
- const loadingState = useMemo<{ type: 'cursor' | 'text' | 'none'; text?: string }>(() => {
109
+ // none: 不显示(有正在进行的活动)
110
+ const loadingState = useMemo<{ type: 'text' | 'none'; text?: string }>(() => {
109
111
  if (!loading) {
110
112
  return { type: 'none' }
111
113
  }
112
114
 
113
- if (parts.length === 0) {
114
- return { type: 'text', text: '正在思考...' }
115
- }
116
-
117
- const lastPart = parts[parts.length - 1]
115
+ // 分模式显示:ask 模式直接生成回答,agent 模式规划下一步
116
+ const waitingText = mode === 'ask' ? '正在生成回答...' : '正在规划下一步...'
118
117
 
119
- // 文本流式输出 不需要额外指示,用户能看到文字在增加
120
- if (lastPart.type === 'text') {
121
- return { type: 'none' }
118
+ // 没有任何 parts 时,显示等待
119
+ if (parts.length === 0) {
120
+ return { type: 'text', text: waitingText }
122
121
  }
123
122
 
124
- // 工具调用完成后 → 显示规划提示
125
- if (lastPart.type === 'tool_call') {
126
- const status = (lastPart as ToolCallPart).status
127
- if (status === 'done' || status === 'error' || status === 'skipped') {
128
- return { type: 'text', text: '正在规划下一步...' }
123
+ // 检查是否有正在进行的活动(如果有,不需要显示等待提示)
124
+ const hasActiveActivity = parts.some(part => {
125
+ // 思考正在进行
126
+ if (part.type === 'thinking' && (part as ThinkingPart).status === 'running') {
127
+ return true
129
128
  }
130
- // 工具正在执行时,卡片本身有状态,不需要额外指示
131
- return { type: 'none' }
132
- }
133
-
134
- // 搜索完成后 → 显示规划提示
135
- if (lastPart.type === 'search') {
136
- if ((lastPart as SearchPart).status === 'done') {
137
- return { type: 'text', text: '正在规划下一步...' }
129
+ // 搜索正在进行
130
+ if (part.type === 'search' && (part as SearchPart).status === 'running') {
131
+ return true
138
132
  }
133
+ // 工具调用正在进行(running 或 pending)
134
+ if (part.type === 'tool_call') {
135
+ const status = (part as ToolCallPart).status
136
+ if (status === 'running' || status === 'pending') {
137
+ return true
138
+ }
139
+ }
140
+ return false
141
+ })
142
+
143
+ // 有正在进行的活动 → 不显示等待提示(活动本身有状态显示)
144
+ if (hasActiveActivity) {
139
145
  return { type: 'none' }
140
146
  }
141
147
 
142
- // 思考完成后 显示规划提示
143
- if (lastPart.type === 'thinking') {
144
- if ((lastPart as ThinkingPart).status === 'done') {
145
- return { type: 'text', text: '正在规划下一步...' }
146
- }
148
+ // 检查最后一个 part 是否是正在流式输出的文本
149
+ const lastPart = parts[parts.length - 1]
150
+ if (lastPart.type === 'text') {
151
+ // 文本正在流式输出 不显示等待提示
147
152
  return { type: 'none' }
148
153
  }
149
154
 
150
- return { type: 'none' }
151
- }, [loading, parts])
155
+ // 没有任何正在进行的活动,但 loading=true 显示等待提示
156
+ // 这包括:工具执行完成后、思考完成后、搜索完成后等"空窗期"
157
+ return { type: 'text', text: waitingText }
158
+ }, [loading, parts, mode])
152
159
 
153
160
  return (
154
161
  <div className={`message-bubble ${role}`}>
@@ -158,6 +165,7 @@ export const MessageBubble: FC<MessageBubbleProps> = ({
158
165
  <ChatInput
159
166
  variant="message"
160
167
  value={userText}
168
+ images={images}
161
169
  mode={inputContext.mode}
162
170
  model={inputContext.model}
163
171
  models={inputContext.models}
@@ -191,13 +199,14 @@ export const MessageBubble: FC<MessageBubbleProps> = ({
191
199
  parts={parts}
192
200
  expandedType={stepsExpandedType}
193
201
  adapter={adapter}
202
+ onCancelToolCall={onCancelToolCall}
194
203
  autoRunConfig={autoRunConfig}
195
204
  onSaveConfig={onSaveConfig}
196
205
  />
197
206
 
198
207
  {/* 加载指示器:等待状态时显示 */}
199
208
  {loadingState.type === 'text' && (
200
- <div className="loading-indicator">
209
+ <div className={`loading-indicator${parts.length > 0 ? ' has-content-above' : ''}`}>
201
210
  <span className="loading-text">{loadingState.text}</span>
202
211
  <span className="loading-shimmer"></span>
203
212
  </div>
@@ -2,3 +2,11 @@
2
2
  display: flex;
3
3
  flex-direction: column;
4
4
  }
5
+
6
+ .part-item {
7
+ margin-top: 8px;
8
+ }
9
+
10
+ .part-item:first-child {
11
+ margin-top: 0;
12
+ }