@huyooo/ai-chat-frontend-react 0.1.2
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/dist/index.d.ts +401 -0
- package/dist/index.js +1421 -0
- package/dist/index.js.map +1 -0
- package/dist/style.css +1036 -0
- package/package.json +55 -0
- package/src/adapter.ts +162 -0
- package/src/components/ChatInput.tsx +368 -0
- package/src/components/ChatPanel.tsx +235 -0
- package/src/components/chat/messages/ExecutionSteps.tsx +234 -0
- package/src/components/chat/messages/MessageBubble.tsx +130 -0
- package/src/components/chat/ui/ChatHeader.tsx +301 -0
- package/src/components/chat/ui/WelcomeMessage.tsx +107 -0
- package/src/hooks/useChat.ts +464 -0
- package/src/index.ts +81 -0
- package/src/styles.css +1036 -0
- package/src/types/index.ts +165 -0
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ChatPanel Component
|
|
3
|
+
* 与 Vue 版本 ChatPanel.vue 保持一致
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { useEffect, useRef, useCallback, type FC, type ReactNode } from 'react'
|
|
7
|
+
import { useChat } from '../hooks/useChat'
|
|
8
|
+
import type { ChatAdapter } from '../adapter'
|
|
9
|
+
import type { ModelConfig, ChatMode } from '../types'
|
|
10
|
+
import { DEFAULT_MODELS } from '../types'
|
|
11
|
+
import { ChatHeader } from './chat/ui/ChatHeader'
|
|
12
|
+
import { WelcomeMessage } from './chat/ui/WelcomeMessage'
|
|
13
|
+
import { MessageBubble } from './chat/messages/MessageBubble'
|
|
14
|
+
import { ChatInput } from './ChatInput'
|
|
15
|
+
|
|
16
|
+
interface ChatPanelProps {
|
|
17
|
+
/** Adapter 实例 */
|
|
18
|
+
adapter?: ChatAdapter
|
|
19
|
+
/** 工作目录 */
|
|
20
|
+
workingDir?: string
|
|
21
|
+
/** 默认模型 */
|
|
22
|
+
defaultModel?: string
|
|
23
|
+
/** 默认模式 */
|
|
24
|
+
defaultMode?: ChatMode
|
|
25
|
+
/** 可用模型列表 */
|
|
26
|
+
models?: ModelConfig[]
|
|
27
|
+
/** 隐藏标题栏 */
|
|
28
|
+
hideHeader?: boolean
|
|
29
|
+
/** 关闭回调(有此属性时显示关闭按钮) */
|
|
30
|
+
onClose?: () => void
|
|
31
|
+
/** 自定义类名 */
|
|
32
|
+
className?: string
|
|
33
|
+
/** 自定义 Markdown 渲染器 */
|
|
34
|
+
renderMarkdown?: (content: string) => ReactNode
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const ChatPanel: FC<ChatPanelProps> = ({
|
|
38
|
+
adapter,
|
|
39
|
+
workingDir,
|
|
40
|
+
defaultModel = 'anthropic/claude-opus-4.5',
|
|
41
|
+
defaultMode = 'agent',
|
|
42
|
+
models = DEFAULT_MODELS,
|
|
43
|
+
hideHeader = false,
|
|
44
|
+
onClose,
|
|
45
|
+
className = '',
|
|
46
|
+
renderMarkdown,
|
|
47
|
+
}) => {
|
|
48
|
+
const messagesRef = useRef<HTMLDivElement>(null)
|
|
49
|
+
|
|
50
|
+
const {
|
|
51
|
+
sessions,
|
|
52
|
+
currentSessionId,
|
|
53
|
+
messages,
|
|
54
|
+
isLoading,
|
|
55
|
+
mode,
|
|
56
|
+
model,
|
|
57
|
+
webSearch,
|
|
58
|
+
thinking,
|
|
59
|
+
loadSessions,
|
|
60
|
+
switchSession,
|
|
61
|
+
createNewSession,
|
|
62
|
+
deleteSession,
|
|
63
|
+
sendMessage,
|
|
64
|
+
cancelRequest,
|
|
65
|
+
copyMessage,
|
|
66
|
+
regenerateMessage,
|
|
67
|
+
setMode,
|
|
68
|
+
setModel,
|
|
69
|
+
setWebSearch,
|
|
70
|
+
setThinking,
|
|
71
|
+
setWorkingDirectory,
|
|
72
|
+
} = useChat({
|
|
73
|
+
adapter,
|
|
74
|
+
defaultModel,
|
|
75
|
+
defaultMode,
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
// 选中的图片
|
|
79
|
+
// const [selectedImages, setSelectedImages] = useState<string[]>([])
|
|
80
|
+
|
|
81
|
+
// 初始化
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
loadSessions()
|
|
84
|
+
}, [loadSessions])
|
|
85
|
+
|
|
86
|
+
// 工作目录变化时更新
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
if (workingDir) {
|
|
89
|
+
setWorkingDirectory(workingDir)
|
|
90
|
+
}
|
|
91
|
+
}, [workingDir, setWorkingDirectory])
|
|
92
|
+
|
|
93
|
+
// 滚动到底部
|
|
94
|
+
const scrollToBottom = useCallback(() => {
|
|
95
|
+
if (messagesRef.current) {
|
|
96
|
+
messagesRef.current.scrollTop = messagesRef.current.scrollHeight
|
|
97
|
+
}
|
|
98
|
+
}, [])
|
|
99
|
+
|
|
100
|
+
// 消息变化时滚动
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
scrollToBottom()
|
|
103
|
+
}, [messages, scrollToBottom])
|
|
104
|
+
|
|
105
|
+
// 发送消息
|
|
106
|
+
const handleSend = useCallback(
|
|
107
|
+
(text: string) => {
|
|
108
|
+
sendMessage(text)
|
|
109
|
+
},
|
|
110
|
+
[sendMessage]
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
// 快捷操作
|
|
114
|
+
const handleQuickAction = useCallback(
|
|
115
|
+
(text: string) => {
|
|
116
|
+
sendMessage(text)
|
|
117
|
+
},
|
|
118
|
+
[sendMessage]
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
// 重新发送(编辑后)
|
|
122
|
+
const handleResend = useCallback(
|
|
123
|
+
(index: number, text: string) => {
|
|
124
|
+
// 删除当前消息及后续消息,然后重新发送
|
|
125
|
+
// 这里简化处理,实际需要更完善的逻辑
|
|
126
|
+
sendMessage(text)
|
|
127
|
+
},
|
|
128
|
+
[sendMessage]
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
// 关闭
|
|
132
|
+
const handleClose = useCallback(() => {
|
|
133
|
+
onClose?.()
|
|
134
|
+
}, [onClose])
|
|
135
|
+
|
|
136
|
+
// 清空所有对话
|
|
137
|
+
const handleClearAll = useCallback(() => {
|
|
138
|
+
console.log('清空所有对话')
|
|
139
|
+
}, [])
|
|
140
|
+
|
|
141
|
+
// 关闭其他对话
|
|
142
|
+
const handleCloseOthers = useCallback(() => {
|
|
143
|
+
console.log('关闭其他对话')
|
|
144
|
+
}, [])
|
|
145
|
+
|
|
146
|
+
// 导出对话
|
|
147
|
+
const handleExport = useCallback(() => {
|
|
148
|
+
console.log('导出对话')
|
|
149
|
+
}, [])
|
|
150
|
+
|
|
151
|
+
// 复制请求 ID
|
|
152
|
+
const handleCopyId = useCallback(() => {
|
|
153
|
+
if (currentSessionId) {
|
|
154
|
+
navigator.clipboard.writeText(currentSessionId)
|
|
155
|
+
console.log('已复制请求 ID:', currentSessionId)
|
|
156
|
+
}
|
|
157
|
+
}, [currentSessionId])
|
|
158
|
+
|
|
159
|
+
// 反馈
|
|
160
|
+
const handleFeedback = useCallback(() => {
|
|
161
|
+
console.log('反馈')
|
|
162
|
+
}, [])
|
|
163
|
+
|
|
164
|
+
// 设置
|
|
165
|
+
const handleSettings = useCallback(() => {
|
|
166
|
+
console.log('Agent 设置')
|
|
167
|
+
}, [])
|
|
168
|
+
|
|
169
|
+
return (
|
|
170
|
+
<div className={`chat-panel ${className}`.trim()}>
|
|
171
|
+
{/* 顶部标题栏 */}
|
|
172
|
+
{!hideHeader && (
|
|
173
|
+
<ChatHeader
|
|
174
|
+
sessions={sessions}
|
|
175
|
+
currentSessionId={currentSessionId}
|
|
176
|
+
showClose={!!onClose}
|
|
177
|
+
onNewSession={createNewSession}
|
|
178
|
+
onSwitchSession={switchSession}
|
|
179
|
+
onDeleteSession={deleteSession}
|
|
180
|
+
onClose={handleClose}
|
|
181
|
+
onClearAll={handleClearAll}
|
|
182
|
+
onCloseOthers={handleCloseOthers}
|
|
183
|
+
onExport={handleExport}
|
|
184
|
+
onCopyId={handleCopyId}
|
|
185
|
+
onFeedback={handleFeedback}
|
|
186
|
+
onSettings={handleSettings}
|
|
187
|
+
/>
|
|
188
|
+
)}
|
|
189
|
+
|
|
190
|
+
{/* 消息列表 */}
|
|
191
|
+
<div ref={messagesRef} className="messages-container">
|
|
192
|
+
{messages.length === 0 ? (
|
|
193
|
+
<WelcomeMessage onQuickAction={handleQuickAction} />
|
|
194
|
+
) : (
|
|
195
|
+
messages.map((msg, index) => (
|
|
196
|
+
<MessageBubble
|
|
197
|
+
key={msg.id}
|
|
198
|
+
role={msg.role}
|
|
199
|
+
content={msg.content}
|
|
200
|
+
images={msg.images}
|
|
201
|
+
thinking={msg.thinking}
|
|
202
|
+
thinkingComplete={msg.thinkingComplete}
|
|
203
|
+
searchResults={msg.searchResults}
|
|
204
|
+
searching={msg.searching}
|
|
205
|
+
toolCalls={msg.toolCalls}
|
|
206
|
+
copied={msg.copied}
|
|
207
|
+
loading={msg.loading}
|
|
208
|
+
onCopy={() => copyMessage(msg.id)}
|
|
209
|
+
onRegenerate={() => regenerateMessage(index)}
|
|
210
|
+
onSend={(text) => handleResend(index, text)}
|
|
211
|
+
renderMarkdown={renderMarkdown}
|
|
212
|
+
/>
|
|
213
|
+
))
|
|
214
|
+
)}
|
|
215
|
+
</div>
|
|
216
|
+
|
|
217
|
+
{/* 输入区域 */}
|
|
218
|
+
<ChatInput
|
|
219
|
+
selectedImages={[]}
|
|
220
|
+
isLoading={isLoading}
|
|
221
|
+
mode={mode}
|
|
222
|
+
model={model}
|
|
223
|
+
models={models}
|
|
224
|
+
webSearchEnabled={webSearch}
|
|
225
|
+
thinkingEnabled={thinking}
|
|
226
|
+
onSend={handleSend}
|
|
227
|
+
onCancel={cancelRequest}
|
|
228
|
+
onModeChange={setMode}
|
|
229
|
+
onModelChange={setModel}
|
|
230
|
+
onWebSearchChange={setWebSearch}
|
|
231
|
+
onThinkingChange={setThinking}
|
|
232
|
+
/>
|
|
233
|
+
</div>
|
|
234
|
+
)
|
|
235
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ExecutionSteps Component
|
|
3
|
+
* 与 Vue 版本 ExecutionSteps.vue 保持一致
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { useState, type FC, type ReactNode } from 'react'
|
|
7
|
+
import {
|
|
8
|
+
ChevronDown,
|
|
9
|
+
ChevronUp,
|
|
10
|
+
Sparkles,
|
|
11
|
+
Globe,
|
|
12
|
+
FileText,
|
|
13
|
+
FileEdit,
|
|
14
|
+
Terminal,
|
|
15
|
+
Search,
|
|
16
|
+
Folder,
|
|
17
|
+
FolderPlus,
|
|
18
|
+
Trash2,
|
|
19
|
+
Image,
|
|
20
|
+
Video,
|
|
21
|
+
Wrench,
|
|
22
|
+
ExternalLink,
|
|
23
|
+
} from 'lucide-react'
|
|
24
|
+
import type { SearchResult, ToolCall } from '../../../types'
|
|
25
|
+
|
|
26
|
+
interface ExecutionStepsProps {
|
|
27
|
+
/** 是否正在加载 */
|
|
28
|
+
loading?: boolean
|
|
29
|
+
/** 是否有消息内容 */
|
|
30
|
+
hasContent?: boolean
|
|
31
|
+
/** 思考内容 */
|
|
32
|
+
thinking?: string
|
|
33
|
+
/** 思考是否完成 */
|
|
34
|
+
thinkingComplete?: boolean
|
|
35
|
+
/** 思考耗时 */
|
|
36
|
+
thinkingDuration?: number
|
|
37
|
+
/** 是否正在搜索 */
|
|
38
|
+
searching?: boolean
|
|
39
|
+
/** 搜索结果 */
|
|
40
|
+
searchResults?: SearchResult[]
|
|
41
|
+
/** 工具调用列表 */
|
|
42
|
+
toolCalls?: ToolCall[]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 步骤项组件 */
|
|
46
|
+
interface StepItemProps {
|
|
47
|
+
icon: ReactNode
|
|
48
|
+
title: string
|
|
49
|
+
status: 'running' | 'completed' | 'error'
|
|
50
|
+
extra?: string
|
|
51
|
+
detail?: ReactNode
|
|
52
|
+
defaultExpanded?: boolean
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const StepItem: FC<StepItemProps> = ({
|
|
56
|
+
icon,
|
|
57
|
+
title,
|
|
58
|
+
status,
|
|
59
|
+
extra,
|
|
60
|
+
detail,
|
|
61
|
+
defaultExpanded = false,
|
|
62
|
+
}) => {
|
|
63
|
+
const [expanded, setExpanded] = useState(defaultExpanded)
|
|
64
|
+
const isRunning = status === 'running'
|
|
65
|
+
const hasDetail = !!detail
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<div className="step-item">
|
|
69
|
+
<button
|
|
70
|
+
className={`step-header${isRunning ? ' running' : ''}`}
|
|
71
|
+
onClick={() => hasDetail && setExpanded(!expanded)}
|
|
72
|
+
disabled={!hasDetail}
|
|
73
|
+
style={{ cursor: hasDetail ? 'pointer' : 'default' }}
|
|
74
|
+
>
|
|
75
|
+
<span className={`step-icon${isRunning ? ' pulse' : ''}`}>{icon}</span>
|
|
76
|
+
<span className="step-title">{title}</span>
|
|
77
|
+
{hasDetail && (
|
|
78
|
+
expanded ? <ChevronUp className="step-chevron" size={12} /> : <ChevronDown className="step-chevron" size={12} />
|
|
79
|
+
)}
|
|
80
|
+
{extra && <span className="step-extra">{extra}</span>}
|
|
81
|
+
</button>
|
|
82
|
+
|
|
83
|
+
{expanded && detail && (
|
|
84
|
+
<div className="step-detail">
|
|
85
|
+
{typeof detail === 'string' ? <pre>{detail}</pre> : detail}
|
|
86
|
+
</div>
|
|
87
|
+
)}
|
|
88
|
+
</div>
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** 获取工具调用的显示名称 */
|
|
93
|
+
function getToolDisplayName(name: string): string {
|
|
94
|
+
const nameMap: Record<string, string> = {
|
|
95
|
+
read_file: '读取文件',
|
|
96
|
+
write_file: '写入文件',
|
|
97
|
+
execute_command: '执行命令',
|
|
98
|
+
search_files: '搜索文件',
|
|
99
|
+
list_directory: '列出目录',
|
|
100
|
+
create_directory: '创建目录',
|
|
101
|
+
delete_file: '删除文件',
|
|
102
|
+
web_search: '网页搜索',
|
|
103
|
+
generate_image: '生成图片',
|
|
104
|
+
image_to_video: '图片转视频',
|
|
105
|
+
}
|
|
106
|
+
return nameMap[name] || name
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** 获取工具调用的图标 */
|
|
110
|
+
function getToolIcon(name: string) {
|
|
111
|
+
switch (name) {
|
|
112
|
+
case 'read_file': return <FileText size={14} />
|
|
113
|
+
case 'write_file': return <FileEdit size={14} />
|
|
114
|
+
case 'execute_command': return <Terminal size={14} />
|
|
115
|
+
case 'search_files': return <Search size={14} />
|
|
116
|
+
case 'list_directory': return <Folder size={14} />
|
|
117
|
+
case 'create_directory': return <FolderPlus size={14} />
|
|
118
|
+
case 'delete_file': return <Trash2 size={14} />
|
|
119
|
+
case 'web_search': return <Globe size={14} />
|
|
120
|
+
case 'generate_image': return <Image size={14} />
|
|
121
|
+
case 'image_to_video': return <Video size={14} />
|
|
122
|
+
default: return <Wrench size={14} />
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** 格式化工具调用参数 */
|
|
127
|
+
function formatToolArgs(args?: Record<string, unknown>): string {
|
|
128
|
+
if (!args) return ''
|
|
129
|
+
return Object.entries(args)
|
|
130
|
+
.map(([key, value]) => `${key}: ${JSON.stringify(value)}`)
|
|
131
|
+
.join('\n')
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export const ExecutionSteps: FC<ExecutionStepsProps> = ({
|
|
135
|
+
loading,
|
|
136
|
+
hasContent,
|
|
137
|
+
thinking,
|
|
138
|
+
thinkingComplete = true,
|
|
139
|
+
thinkingDuration,
|
|
140
|
+
searching,
|
|
141
|
+
searchResults,
|
|
142
|
+
toolCalls,
|
|
143
|
+
}) => {
|
|
144
|
+
// 判断是否有任何执行步骤
|
|
145
|
+
const hasSteps =
|
|
146
|
+
thinking ||
|
|
147
|
+
searching ||
|
|
148
|
+
(searchResults && searchResults.length > 0) ||
|
|
149
|
+
(toolCalls && toolCalls.length > 0) ||
|
|
150
|
+
(loading && !hasContent)
|
|
151
|
+
|
|
152
|
+
if (!hasSteps) return null
|
|
153
|
+
|
|
154
|
+
return (
|
|
155
|
+
<div className="execution-steps">
|
|
156
|
+
{/* 正在规划 */}
|
|
157
|
+
{loading && !hasContent && !thinking && !searching && (!toolCalls || toolCalls.length === 0) && (
|
|
158
|
+
<StepItem
|
|
159
|
+
icon={<Sparkles size={14} />}
|
|
160
|
+
title="正在规划下一步..."
|
|
161
|
+
status="running"
|
|
162
|
+
/>
|
|
163
|
+
)}
|
|
164
|
+
|
|
165
|
+
{/* 思考过程 */}
|
|
166
|
+
{thinking && (
|
|
167
|
+
<StepItem
|
|
168
|
+
icon={<Sparkles size={14} />}
|
|
169
|
+
title={thinkingComplete ? '思考完成' : '思考中...'}
|
|
170
|
+
status={thinkingComplete ? 'completed' : 'running'}
|
|
171
|
+
extra={thinkingDuration ? `${thinkingDuration}s` : undefined}
|
|
172
|
+
detail={thinking}
|
|
173
|
+
/>
|
|
174
|
+
)}
|
|
175
|
+
|
|
176
|
+
{/* 搜索 */}
|
|
177
|
+
{(searching || (searchResults && searchResults.length > 0)) && (
|
|
178
|
+
<StepItem
|
|
179
|
+
icon={<Globe size={14} />}
|
|
180
|
+
title={searching ? '搜索中...' : `搜索完成 ${searchResults?.length || 0} 条结果`}
|
|
181
|
+
status={searching ? 'running' : 'completed'}
|
|
182
|
+
detail={
|
|
183
|
+
searchResults && searchResults.length > 0 ? (
|
|
184
|
+
<div>
|
|
185
|
+
{searchResults.map((result, i) => (
|
|
186
|
+
<a
|
|
187
|
+
key={i}
|
|
188
|
+
href={result.url}
|
|
189
|
+
target="_blank"
|
|
190
|
+
rel="noopener noreferrer"
|
|
191
|
+
className="search-result-item"
|
|
192
|
+
>
|
|
193
|
+
<div className="search-result-title">
|
|
194
|
+
<span>{result.title}</span>
|
|
195
|
+
<ExternalLink size={12} style={{ opacity: 0.5 }} />
|
|
196
|
+
</div>
|
|
197
|
+
<div className="search-result-snippet">{result.snippet}</div>
|
|
198
|
+
</a>
|
|
199
|
+
))}
|
|
200
|
+
</div>
|
|
201
|
+
) : undefined
|
|
202
|
+
}
|
|
203
|
+
/>
|
|
204
|
+
)}
|
|
205
|
+
|
|
206
|
+
{/* 工具调用 */}
|
|
207
|
+
{toolCalls &&
|
|
208
|
+
toolCalls.map((call, index) => (
|
|
209
|
+
<StepItem
|
|
210
|
+
key={`tool-${index}`}
|
|
211
|
+
icon={getToolIcon(call.name)}
|
|
212
|
+
title={`${getToolDisplayName(call.name)}${call.status === 'running' ? '...' : ''}`}
|
|
213
|
+
status={call.status === 'running' ? 'running' : call.status === 'error' ? 'error' : 'completed'}
|
|
214
|
+
detail={
|
|
215
|
+
<div>
|
|
216
|
+
{call.args && (
|
|
217
|
+
<div style={{ marginBottom: call.result ? 8 : 0 }}>
|
|
218
|
+
<div style={{ fontSize: 10, color: 'var(--chat-text-muted)', marginBottom: 4 }}>参数</div>
|
|
219
|
+
<pre style={{ margin: 0 }}>{formatToolArgs(call.args)}</pre>
|
|
220
|
+
</div>
|
|
221
|
+
)}
|
|
222
|
+
{call.result && (
|
|
223
|
+
<div>
|
|
224
|
+
<div style={{ fontSize: 10, color: 'var(--chat-text-muted)', marginBottom: 4 }}>结果</div>
|
|
225
|
+
<pre style={{ margin: 0, maxHeight: 160, overflow: 'auto' }}>{call.result}</pre>
|
|
226
|
+
</div>
|
|
227
|
+
)}
|
|
228
|
+
</div>
|
|
229
|
+
}
|
|
230
|
+
/>
|
|
231
|
+
))}
|
|
232
|
+
</div>
|
|
233
|
+
)
|
|
234
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MessageBubble Component
|
|
3
|
+
* 与 Vue 版本 MessageBubble.vue 保持一致
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { type FC, type ReactNode } from 'react'
|
|
7
|
+
import { Copy, Check, RefreshCw } from 'lucide-react'
|
|
8
|
+
import { ExecutionSteps } from './ExecutionSteps'
|
|
9
|
+
import { ChatInput } from '../../ChatInput'
|
|
10
|
+
import type { SearchResult, ToolCall } from '../../../types'
|
|
11
|
+
|
|
12
|
+
interface MessageBubbleProps {
|
|
13
|
+
role: 'user' | 'assistant'
|
|
14
|
+
content: string
|
|
15
|
+
images?: string[]
|
|
16
|
+
thinking?: string
|
|
17
|
+
thinkingComplete?: boolean
|
|
18
|
+
thinkingDuration?: number
|
|
19
|
+
searchResults?: SearchResult[]
|
|
20
|
+
searching?: boolean
|
|
21
|
+
toolCalls?: ToolCall[]
|
|
22
|
+
copied?: boolean
|
|
23
|
+
loading?: boolean
|
|
24
|
+
onCopy?: () => void
|
|
25
|
+
onRegenerate?: () => void
|
|
26
|
+
/** 编辑用户消息后重新发送 */
|
|
27
|
+
onSend?: (text: string) => void
|
|
28
|
+
/** 自定义 Markdown 渲染器 */
|
|
29
|
+
renderMarkdown?: (content: string) => ReactNode
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 默认 Markdown 渲染(简单处理) */
|
|
33
|
+
function defaultRenderMarkdown(content: string): ReactNode {
|
|
34
|
+
// 简单的 Markdown 处理:代码块
|
|
35
|
+
const parts = content.split(/(```[\s\S]*?```)/g)
|
|
36
|
+
|
|
37
|
+
return parts.map((part, i) => {
|
|
38
|
+
if (part.startsWith('```') && part.endsWith('```')) {
|
|
39
|
+
const code = part.slice(3, -3)
|
|
40
|
+
const firstLine = code.indexOf('\n')
|
|
41
|
+
const lang = firstLine > 0 ? code.slice(0, firstLine).trim() : ''
|
|
42
|
+
const codeContent = firstLine > 0 ? code.slice(firstLine + 1) : code
|
|
43
|
+
return (
|
|
44
|
+
<pre key={i}>
|
|
45
|
+
<code>{codeContent}</code>
|
|
46
|
+
</pre>
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
// 处理行内代码
|
|
50
|
+
const inlineParts = part.split(/(`[^`]+`)/g)
|
|
51
|
+
return (
|
|
52
|
+
<span key={i}>
|
|
53
|
+
{inlineParts.map((p, j) => {
|
|
54
|
+
if (p.startsWith('`') && p.endsWith('`')) {
|
|
55
|
+
return <code key={j}>{p.slice(1, -1)}</code>
|
|
56
|
+
}
|
|
57
|
+
return p
|
|
58
|
+
})}
|
|
59
|
+
</span>
|
|
60
|
+
)
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const MessageBubble: FC<MessageBubbleProps> = ({
|
|
65
|
+
role,
|
|
66
|
+
content,
|
|
67
|
+
images,
|
|
68
|
+
thinking,
|
|
69
|
+
thinkingComplete = true,
|
|
70
|
+
thinkingDuration,
|
|
71
|
+
searchResults,
|
|
72
|
+
searching,
|
|
73
|
+
toolCalls,
|
|
74
|
+
copied,
|
|
75
|
+
loading,
|
|
76
|
+
onCopy,
|
|
77
|
+
onRegenerate,
|
|
78
|
+
onSend,
|
|
79
|
+
renderMarkdown = defaultRenderMarkdown,
|
|
80
|
+
}) => {
|
|
81
|
+
const isUser = role === 'user'
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<div className="message-bubble">
|
|
85
|
+
{/* 用户消息 - 复用 ChatInput 组件 */}
|
|
86
|
+
{isUser ? (
|
|
87
|
+
<ChatInput
|
|
88
|
+
variant="message"
|
|
89
|
+
value={content}
|
|
90
|
+
selectedImages={images}
|
|
91
|
+
onSend={onSend}
|
|
92
|
+
/>
|
|
93
|
+
) : (
|
|
94
|
+
/* AI 消息 */
|
|
95
|
+
<div className="assistant-message">
|
|
96
|
+
{/* 执行步骤列表 */}
|
|
97
|
+
<ExecutionSteps
|
|
98
|
+
loading={loading}
|
|
99
|
+
hasContent={!!content}
|
|
100
|
+
thinking={thinking}
|
|
101
|
+
thinkingComplete={thinkingComplete}
|
|
102
|
+
thinkingDuration={thinkingDuration}
|
|
103
|
+
searching={searching}
|
|
104
|
+
searchResults={searchResults}
|
|
105
|
+
toolCalls={toolCalls}
|
|
106
|
+
/>
|
|
107
|
+
|
|
108
|
+
{/* 消息内容 */}
|
|
109
|
+
{content && (
|
|
110
|
+
<div className="message-content">
|
|
111
|
+
{renderMarkdown(content)}
|
|
112
|
+
</div>
|
|
113
|
+
)}
|
|
114
|
+
|
|
115
|
+
{/* 操作按钮 */}
|
|
116
|
+
{content && !loading && (
|
|
117
|
+
<div className="message-actions">
|
|
118
|
+
<button className={`action-btn${copied ? ' copied' : ''}`} onClick={onCopy} title="复制">
|
|
119
|
+
{copied ? <Check size={14} /> : <Copy size={14} />}
|
|
120
|
+
</button>
|
|
121
|
+
<button className="action-btn" onClick={onRegenerate} title="重新生成">
|
|
122
|
+
<RefreshCw size={14} />
|
|
123
|
+
</button>
|
|
124
|
+
</div>
|
|
125
|
+
)}
|
|
126
|
+
</div>
|
|
127
|
+
)}
|
|
128
|
+
</div>
|
|
129
|
+
)
|
|
130
|
+
}
|