@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.
- package/README.md +99 -84
- package/dist/KaTeX_AMS-Regular-CYEKBG2K.woff +0 -0
- package/dist/KaTeX_AMS-Regular-JKX5W2C4.ttf +0 -0
- package/dist/KaTeX_AMS-Regular-U6PRYMIZ.woff2 +0 -0
- package/dist/KaTeX_Caligraphic-Bold-5QL5CMTE.woff2 +0 -0
- package/dist/KaTeX_Caligraphic-Bold-WZ3QSGD3.woff +0 -0
- package/dist/KaTeX_Caligraphic-Bold-ZTS3R3HK.ttf +0 -0
- package/dist/KaTeX_Caligraphic-Regular-3LKEU76G.woff +0 -0
- package/dist/KaTeX_Caligraphic-Regular-A7XRTZ5Q.ttf +0 -0
- package/dist/KaTeX_Caligraphic-Regular-KX5MEWCF.woff2 +0 -0
- package/dist/KaTeX_Fraktur-Bold-2QVFK6NQ.woff2 +0 -0
- package/dist/KaTeX_Fraktur-Bold-T4SWXBMT.woff +0 -0
- package/dist/KaTeX_Fraktur-Bold-WGHVTYOR.ttf +0 -0
- package/dist/KaTeX_Fraktur-Regular-2PEIFJSJ.woff2 +0 -0
- package/dist/KaTeX_Fraktur-Regular-5U4OPH2X.ttf +0 -0
- package/dist/KaTeX_Fraktur-Regular-PQMHCIK6.woff +0 -0
- package/dist/KaTeX_Main-Bold-2GA4IZIN.woff +0 -0
- package/dist/KaTeX_Main-Bold-W5FBVCZM.ttf +0 -0
- package/dist/KaTeX_Main-Bold-YP5VVQRP.woff2 +0 -0
- package/dist/KaTeX_Main-BoldItalic-4P4C7HJH.woff +0 -0
- package/dist/KaTeX_Main-BoldItalic-N4V3DX7S.woff2 +0 -0
- package/dist/KaTeX_Main-BoldItalic-ODMLBJJQ.ttf +0 -0
- package/dist/KaTeX_Main-Italic-I43T2HSR.ttf +0 -0
- package/dist/KaTeX_Main-Italic-RELBIK7M.woff2 +0 -0
- package/dist/KaTeX_Main-Italic-SASNQFN2.woff +0 -0
- package/dist/KaTeX_Main-Regular-ARRPAO67.woff2 +0 -0
- package/dist/KaTeX_Main-Regular-P5I74A2A.woff +0 -0
- package/dist/KaTeX_Main-Regular-W74P5G27.ttf +0 -0
- package/dist/KaTeX_Math-BoldItalic-6EBV3DK5.woff +0 -0
- package/dist/KaTeX_Math-BoldItalic-K4WTGH3J.woff2 +0 -0
- package/dist/KaTeX_Math-BoldItalic-VB447A4D.ttf +0 -0
- package/dist/KaTeX_Math-Italic-6KGCHLFN.woff2 +0 -0
- package/dist/KaTeX_Math-Italic-KKK3USB2.woff +0 -0
- package/dist/KaTeX_Math-Italic-SON4MRCA.ttf +0 -0
- package/dist/KaTeX_SansSerif-Bold-RRNVJFFW.woff2 +0 -0
- package/dist/KaTeX_SansSerif-Bold-STQ6RXC7.ttf +0 -0
- package/dist/KaTeX_SansSerif-Bold-X5M5EMOD.woff +0 -0
- package/dist/KaTeX_SansSerif-Italic-HMPFTM52.woff2 +0 -0
- package/dist/KaTeX_SansSerif-Italic-PSN4QKYX.woff +0 -0
- package/dist/KaTeX_SansSerif-Italic-WTBAZBGY.ttf +0 -0
- package/dist/KaTeX_SansSerif-Regular-2TL3USAE.ttf +0 -0
- package/dist/KaTeX_SansSerif-Regular-OQCII6EP.woff +0 -0
- package/dist/KaTeX_SansSerif-Regular-XIQ62X4E.woff2 +0 -0
- package/dist/KaTeX_Script-Regular-72OLXYNA.ttf +0 -0
- package/dist/KaTeX_Script-Regular-A5IFOEBS.woff +0 -0
- package/dist/KaTeX_Script-Regular-APUWIHLP.woff2 +0 -0
- package/dist/KaTeX_Size1-Regular-4HRHTS65.woff +0 -0
- package/dist/KaTeX_Size1-Regular-5LRUTBFT.woff2 +0 -0
- package/dist/KaTeX_Size1-Regular-7K6AASVL.ttf +0 -0
- package/dist/KaTeX_Size2-Regular-222HN3GT.ttf +0 -0
- package/dist/KaTeX_Size2-Regular-K5ZHAIS6.woff +0 -0
- package/dist/KaTeX_Size2-Regular-LELKET5D.woff2 +0 -0
- package/dist/KaTeX_Size3-Regular-TLFPAHDE.woff +0 -0
- package/dist/KaTeX_Size3-Regular-UFCO6WCA.ttf +0 -0
- package/dist/KaTeX_Size3-Regular-WQRQ47UD.woff2 +0 -0
- package/dist/KaTeX_Size4-Regular-7PGNVPQK.ttf +0 -0
- package/dist/KaTeX_Size4-Regular-CDMV7U5C.woff2 +0 -0
- package/dist/KaTeX_Size4-Regular-PKMWZHNC.woff +0 -0
- package/dist/KaTeX_Typewriter-Regular-3F5K6SQ6.ttf +0 -0
- package/dist/KaTeX_Typewriter-Regular-MJMFSK64.woff +0 -0
- package/dist/KaTeX_Typewriter-Regular-VBYJ4NRC.woff2 +0 -0
- package/dist/index.css +2156 -603
- package/dist/index.css.map +1 -1
- package/dist/index.d.ts +126 -92
- package/dist/index.js +1605 -976
- package/dist/index.js.map +1 -1
- package/dist/style.css +130 -0
- package/package.json +3 -3
- package/src/components/ChatPanel.tsx +82 -19
- package/src/components/common/SettingsPanel.css +81 -0
- package/src/components/common/SettingsPanel.tsx +96 -1
- package/src/components/input/ChatInput.css +0 -1
- package/src/components/input/ChatInput.tsx +48 -26
- package/src/components/input/DropdownSelector.css +66 -0
- package/src/components/input/DropdownSelector.tsx +157 -19
- package/src/components/message/MessageBubble.css +5 -2
- package/src/components/message/MessageBubble.tsx +44 -35
- package/src/components/message/PartsRenderer.css +8 -0
- package/src/components/message/PartsRenderer.tsx +137 -83
- package/src/components/message/parts/CollapsibleCard.css +4 -2
- package/src/components/message/parts/CollapsibleCard.tsx +4 -1
- package/src/components/message/parts/ImagePart.css +0 -1
- package/src/components/message/parts/TextPart.css +574 -5
- package/src/components/message/parts/TextPart.tsx +201 -8
- package/src/components/message/parts/ToolCallPart.css +139 -115
- package/src/components/message/parts/ToolCallPart.tsx +138 -134
- package/src/components/message/parts/ToolResultPart.css +0 -1
- package/src/components/message/parts/index.ts +3 -1
- package/src/components/message/parts/visual-predicate.ts +43 -0
- package/src/components/message/parts/visual-render.ts +19 -0
- package/src/components/message/parts/visual.ts +12 -0
- package/src/context/RenderersContext.tsx +19 -25
- package/src/hooks/useChat.ts +567 -79
- package/src/hooks/useImageUpload.ts +104 -12
- package/src/hooks/useVoiceInput.ts +17 -0
- package/src/index.ts +19 -16
- package/src/styles.css +130 -0
- package/src/types/index.ts +52 -68
- package/src/components/message/ContentRenderer.tsx +0 -63
- package/src/components/message/ToolResultRenderer.tsx +0 -21
- package/src/components/message/blocks/CodeBlock.tsx +0 -60
- package/src/components/message/blocks/TextBlock.tsx +0 -15
- package/src/components/message/blocks/blocks.css +0 -141
- package/src/components/message/blocks/index.ts +0 -6
- package/src/components/message/parts/ToolResultPart.tsx +0 -96
- package/src/components/message/tool-results/DefaultToolResult.tsx +0 -26
- package/src/components/message/tool-results/SearchResults.tsx +0 -69
- package/src/components/message/tool-results/WeatherCard.tsx +0 -63
- package/src/components/message/tool-results/index.ts +0 -7
- 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
|
|
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
|
-
//
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
return
|
|
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
|
-
|
|
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="
|
|
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
|
-
<
|
|
237
|
+
<div
|
|
161
238
|
key={opt.value}
|
|
162
|
-
className=
|
|
163
|
-
|
|
239
|
+
className="dropdown-item-wrapper"
|
|
240
|
+
onMouseEnter={(e) => handleItemHover(e, opt)}
|
|
241
|
+
onMouseLeave={() => {
|
|
242
|
+
setHoveredOption(null);
|
|
243
|
+
hoveredItemRef.current = null;
|
|
244
|
+
}}
|
|
164
245
|
>
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
<
|
|
264
|
+
<div
|
|
179
265
|
key={opt.value}
|
|
180
|
-
className=
|
|
181
|
-
|
|
266
|
+
className="dropdown-item-wrapper"
|
|
267
|
+
onMouseEnter={(e) => handleItemHover(e, opt)}
|
|
268
|
+
onMouseLeave={() => {
|
|
269
|
+
setHoveredOption(null);
|
|
270
|
+
hoveredItemRef.current = null;
|
|
271
|
+
}}
|
|
182
272
|
>
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
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: '
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
const lastPart = parts[parts.length - 1]
|
|
115
|
+
// 分模式显示:ask 模式直接生成回答,agent 模式规划下一步
|
|
116
|
+
const waitingText = mode === 'ask' ? '正在生成回答...' : '正在规划下一步...'
|
|
118
117
|
|
|
119
|
-
//
|
|
120
|
-
if (
|
|
121
|
-
return { type: '
|
|
118
|
+
// 没有任何 parts 时,显示等待
|
|
119
|
+
if (parts.length === 0) {
|
|
120
|
+
return { type: 'text', text: waitingText }
|
|
122
121
|
}
|
|
123
122
|
|
|
124
|
-
//
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
if (
|
|
128
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
151
|
-
|
|
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=
|
|
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>
|