@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
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@huyooo/ai-chat-frontend-react",
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "AI Chat Frontend - React components with adapter pattern",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"module": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"development": "./src/index.ts",
|
|
13
|
+
"import": "./dist/index.js",
|
|
14
|
+
"default": "./dist/index.js"
|
|
15
|
+
},
|
|
16
|
+
"./style.css": "./dist/style.css"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"dist",
|
|
20
|
+
"src"
|
|
21
|
+
],
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "tsup",
|
|
24
|
+
"dev": "tsup --watch",
|
|
25
|
+
"typecheck": "tsc --noEmit",
|
|
26
|
+
"clean": "rm -rf dist"
|
|
27
|
+
},
|
|
28
|
+
"peerDependencies": {
|
|
29
|
+
"react": ">=18.0.0",
|
|
30
|
+
"react-dom": ">=18.0.0"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"lucide-react": "^0.460.0"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@types/node": "^22.0.0",
|
|
37
|
+
"@types/react": "^18.0.0",
|
|
38
|
+
"@types/react-dom": "^18.0.0",
|
|
39
|
+
"react": "^18.0.0",
|
|
40
|
+
"react-dom": "^18.0.0",
|
|
41
|
+
"tsup": "^8.0.0",
|
|
42
|
+
"typescript": "^5.0.0"
|
|
43
|
+
},
|
|
44
|
+
"keywords": [
|
|
45
|
+
"ai",
|
|
46
|
+
"chat",
|
|
47
|
+
"react",
|
|
48
|
+
"frontend"
|
|
49
|
+
],
|
|
50
|
+
"author": "huyooo",
|
|
51
|
+
"license": "MIT",
|
|
52
|
+
"publishConfig": {
|
|
53
|
+
"access": "public"
|
|
54
|
+
}
|
|
55
|
+
}
|
package/src/adapter.ts
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chat Adapter 接口定义
|
|
3
|
+
* 解耦前端组件与后端通信方式
|
|
4
|
+
*
|
|
5
|
+
* 与 Vue 版本保持一致
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { SessionRecord, MessageRecord, ChatMode, ThinkingMode, SearchResult } from './types'
|
|
9
|
+
|
|
10
|
+
/** 聊天进度类型 */
|
|
11
|
+
export type ChatProgressType =
|
|
12
|
+
| 'thinking'
|
|
13
|
+
| 'search_start'
|
|
14
|
+
| 'search_result'
|
|
15
|
+
| 'tool_call'
|
|
16
|
+
| 'tool_result'
|
|
17
|
+
| 'text_delta'
|
|
18
|
+
| 'text'
|
|
19
|
+
| 'done'
|
|
20
|
+
| 'error'
|
|
21
|
+
|
|
22
|
+
/** 思考数据 */
|
|
23
|
+
export interface ThinkingData {
|
|
24
|
+
content: string
|
|
25
|
+
isComplete: boolean
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** 工具调用数据 */
|
|
29
|
+
export interface ToolCallData {
|
|
30
|
+
name: string
|
|
31
|
+
args: Record<string, unknown>
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 工具结果数据 */
|
|
35
|
+
export interface ToolResultData {
|
|
36
|
+
name: string
|
|
37
|
+
result: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 图片数据 */
|
|
41
|
+
export interface ImageData {
|
|
42
|
+
base64: string
|
|
43
|
+
mimeType: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 聊天进度事件 */
|
|
47
|
+
export interface ChatProgress {
|
|
48
|
+
type: ChatProgressType
|
|
49
|
+
data: string | ThinkingData | ToolCallData | ToolResultData | { results: SearchResult[] }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 发送消息选项 */
|
|
53
|
+
export interface SendMessageOptions {
|
|
54
|
+
mode: ChatMode
|
|
55
|
+
model: string
|
|
56
|
+
enableWebSearch: boolean
|
|
57
|
+
thinkingMode: ThinkingMode
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** 创建会话选项 */
|
|
61
|
+
export interface CreateSessionOptions {
|
|
62
|
+
title: string
|
|
63
|
+
model: string
|
|
64
|
+
mode: ChatMode
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** 更新会话选项 */
|
|
68
|
+
export interface UpdateSessionOptions {
|
|
69
|
+
title?: string
|
|
70
|
+
model?: string
|
|
71
|
+
mode?: ChatMode
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 保存消息选项 */
|
|
75
|
+
export interface SaveMessageOptions {
|
|
76
|
+
sessionId: string
|
|
77
|
+
role: 'user' | 'assistant'
|
|
78
|
+
content: string
|
|
79
|
+
thinking?: string
|
|
80
|
+
toolCalls?: string
|
|
81
|
+
searchResults?: string
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Chat Adapter 接口
|
|
86
|
+
* 所有后端通信实现都需要实现此接口
|
|
87
|
+
*/
|
|
88
|
+
export interface ChatAdapter {
|
|
89
|
+
/** 获取所有会话 */
|
|
90
|
+
getSessions(): Promise<SessionRecord[]>
|
|
91
|
+
|
|
92
|
+
/** 创建新会话 */
|
|
93
|
+
createSession(options: CreateSessionOptions): Promise<SessionRecord>
|
|
94
|
+
|
|
95
|
+
/** 更新会话 */
|
|
96
|
+
updateSession(sessionId: string, options: UpdateSessionOptions): Promise<void>
|
|
97
|
+
|
|
98
|
+
/** 删除会话 */
|
|
99
|
+
deleteSession(sessionId: string): Promise<void>
|
|
100
|
+
|
|
101
|
+
/** 获取会话消息 */
|
|
102
|
+
getMessages(sessionId: string): Promise<MessageRecord[]>
|
|
103
|
+
|
|
104
|
+
/** 保存消息 */
|
|
105
|
+
saveMessage(options: SaveMessageOptions): Promise<MessageRecord>
|
|
106
|
+
|
|
107
|
+
/** 发送消息并获取流式响应 */
|
|
108
|
+
sendMessage(
|
|
109
|
+
content: string,
|
|
110
|
+
options: SendMessageOptions,
|
|
111
|
+
images?: string[]
|
|
112
|
+
): AsyncGenerator<ChatProgress, void, unknown>
|
|
113
|
+
|
|
114
|
+
/** 取消当前请求 */
|
|
115
|
+
cancel(): void
|
|
116
|
+
|
|
117
|
+
/** 设置工作目录 */
|
|
118
|
+
setWorkingDir?(dir: string): void
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* 创建空 Adapter(用于测试或无后端场景)
|
|
123
|
+
*/
|
|
124
|
+
export function createNullAdapter(): ChatAdapter {
|
|
125
|
+
return {
|
|
126
|
+
async getSessions() {
|
|
127
|
+
return []
|
|
128
|
+
},
|
|
129
|
+
async createSession(options) {
|
|
130
|
+
return {
|
|
131
|
+
id: Date.now().toString(),
|
|
132
|
+
title: options.title,
|
|
133
|
+
model: options.model,
|
|
134
|
+
mode: options.mode,
|
|
135
|
+
createdAt: new Date(),
|
|
136
|
+
updatedAt: new Date(),
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
async updateSession() {},
|
|
140
|
+
async deleteSession() {},
|
|
141
|
+
async getMessages() {
|
|
142
|
+
return []
|
|
143
|
+
},
|
|
144
|
+
async saveMessage(options) {
|
|
145
|
+
return {
|
|
146
|
+
id: Date.now().toString(),
|
|
147
|
+
sessionId: options.sessionId,
|
|
148
|
+
role: options.role,
|
|
149
|
+
content: options.content,
|
|
150
|
+
thinking: options.thinking,
|
|
151
|
+
toolCalls: options.toolCalls,
|
|
152
|
+
searchResults: options.searchResults,
|
|
153
|
+
timestamp: new Date(),
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
async *sendMessage() {
|
|
157
|
+
yield { type: 'text', data: '无可用的 Adapter' }
|
|
158
|
+
yield { type: 'done', data: '' }
|
|
159
|
+
},
|
|
160
|
+
cancel() {},
|
|
161
|
+
}
|
|
162
|
+
}
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ChatInput Component
|
|
3
|
+
* 与 Vue 版本 ChatInput.vue 保持一致
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { useState, useRef, useCallback, useEffect, type FC } from 'react'
|
|
7
|
+
import {
|
|
8
|
+
X,
|
|
9
|
+
ChevronDown,
|
|
10
|
+
Check,
|
|
11
|
+
Globe,
|
|
12
|
+
Sparkles,
|
|
13
|
+
ImageIcon,
|
|
14
|
+
Square,
|
|
15
|
+
ArrowUp,
|
|
16
|
+
Zap,
|
|
17
|
+
MessageCircle,
|
|
18
|
+
AtSign,
|
|
19
|
+
Mic,
|
|
20
|
+
} from 'lucide-react'
|
|
21
|
+
import type { ChatMode, ModelConfig } from '../types'
|
|
22
|
+
import { DEFAULT_MODELS } from '../types'
|
|
23
|
+
|
|
24
|
+
interface ChatInputProps {
|
|
25
|
+
/** 变体模式:input-底部输入框,message-历史消息 */
|
|
26
|
+
variant?: 'input' | 'message'
|
|
27
|
+
/** 受控值(用于历史消息编辑) */
|
|
28
|
+
value?: string
|
|
29
|
+
selectedImages?: string[]
|
|
30
|
+
isLoading?: boolean
|
|
31
|
+
mode?: ChatMode
|
|
32
|
+
model?: string
|
|
33
|
+
models?: ModelConfig[]
|
|
34
|
+
webSearchEnabled?: boolean
|
|
35
|
+
thinkingEnabled?: boolean
|
|
36
|
+
onSend?: (text: string) => void
|
|
37
|
+
onRemoveImage?: (index: number) => void
|
|
38
|
+
onCancel?: () => void
|
|
39
|
+
onUploadImage?: () => void
|
|
40
|
+
onAtContext?: () => void
|
|
41
|
+
onModeChange?: (mode: ChatMode) => void
|
|
42
|
+
onModelChange?: (model: string) => void
|
|
43
|
+
onWebSearchChange?: (enabled: boolean) => void
|
|
44
|
+
onThinkingChange?: (enabled: boolean) => void
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 模式配置 */
|
|
48
|
+
const MODES = [
|
|
49
|
+
{ value: 'agent' as const, label: 'Agent', Icon: Zap },
|
|
50
|
+
{ value: 'ask' as const, label: 'Ask', Icon: MessageCircle },
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
export const ChatInput: FC<ChatInputProps> = ({
|
|
54
|
+
variant = 'input',
|
|
55
|
+
value = '',
|
|
56
|
+
selectedImages = [],
|
|
57
|
+
isLoading = false,
|
|
58
|
+
mode = 'agent',
|
|
59
|
+
model = '',
|
|
60
|
+
models = DEFAULT_MODELS,
|
|
61
|
+
webSearchEnabled = false,
|
|
62
|
+
thinkingEnabled = false,
|
|
63
|
+
onSend,
|
|
64
|
+
onRemoveImage,
|
|
65
|
+
onCancel,
|
|
66
|
+
onUploadImage,
|
|
67
|
+
onAtContext,
|
|
68
|
+
onModeChange,
|
|
69
|
+
onModelChange,
|
|
70
|
+
onWebSearchChange,
|
|
71
|
+
onThinkingChange,
|
|
72
|
+
}) => {
|
|
73
|
+
const isMessageVariant = variant === 'message'
|
|
74
|
+
|
|
75
|
+
const [inputText, setInputText] = useState(value)
|
|
76
|
+
const [isFocused, setIsFocused] = useState(false)
|
|
77
|
+
const [modeMenuOpen, setModeMenuOpen] = useState(false)
|
|
78
|
+
const [modelMenuOpen, setModelMenuOpen] = useState(false)
|
|
79
|
+
|
|
80
|
+
const inputRef = useRef<HTMLTextAreaElement>(null)
|
|
81
|
+
const containerRef = useRef<HTMLDivElement>(null)
|
|
82
|
+
|
|
83
|
+
// 同步外部 value
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
setInputText(value)
|
|
86
|
+
}, [value])
|
|
87
|
+
|
|
88
|
+
const currentMode = MODES.find((m) => m.value === mode) || MODES[0]
|
|
89
|
+
const currentModel = models.find((m) => m.model === model)
|
|
90
|
+
|
|
91
|
+
// 是否显示工具栏
|
|
92
|
+
const showToolbar = !isMessageVariant || isFocused
|
|
93
|
+
|
|
94
|
+
// 预览
|
|
95
|
+
const selectedPreview = selectedImages.slice(0, 3)
|
|
96
|
+
|
|
97
|
+
// 占位符
|
|
98
|
+
const placeholder = selectedImages.length > 0
|
|
99
|
+
? '描述你想要的效果...'
|
|
100
|
+
: mode === 'ask'
|
|
101
|
+
? '有什么问题想问我?'
|
|
102
|
+
: '描述任务,@ 添加上下文'
|
|
103
|
+
|
|
104
|
+
// 自动调整高度
|
|
105
|
+
const adjustTextareaHeight = useCallback(() => {
|
|
106
|
+
if (inputRef.current) {
|
|
107
|
+
inputRef.current.style.height = 'auto'
|
|
108
|
+
const scrollHeight = inputRef.current.scrollHeight
|
|
109
|
+
inputRef.current.style.height = `${Math.min(scrollHeight, 150)}px`
|
|
110
|
+
}
|
|
111
|
+
}, [])
|
|
112
|
+
|
|
113
|
+
// 发送或取消
|
|
114
|
+
const handleSendOrCancel = useCallback(() => {
|
|
115
|
+
if (isLoading) {
|
|
116
|
+
onCancel?.()
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const text = inputText.trim()
|
|
121
|
+
if (!text) return
|
|
122
|
+
|
|
123
|
+
onSend?.(text)
|
|
124
|
+
|
|
125
|
+
if (!isMessageVariant) {
|
|
126
|
+
setInputText('')
|
|
127
|
+
if (inputRef.current) {
|
|
128
|
+
inputRef.current.style.height = 'auto'
|
|
129
|
+
}
|
|
130
|
+
inputRef.current?.focus()
|
|
131
|
+
} else {
|
|
132
|
+
setIsFocused(false)
|
|
133
|
+
}
|
|
134
|
+
}, [isLoading, inputText, onSend, onCancel, isMessageVariant])
|
|
135
|
+
|
|
136
|
+
// 键盘事件
|
|
137
|
+
const handleKeydown = useCallback(
|
|
138
|
+
(event: React.KeyboardEvent) => {
|
|
139
|
+
if (event.key === 'Enter' && !event.shiftKey) {
|
|
140
|
+
event.preventDefault()
|
|
141
|
+
handleSendOrCancel()
|
|
142
|
+
} else {
|
|
143
|
+
setTimeout(adjustTextareaHeight, 0)
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
[handleSendOrCancel, adjustTextareaHeight]
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
// 选择模式
|
|
150
|
+
const selectMode = useCallback(
|
|
151
|
+
(value: ChatMode) => {
|
|
152
|
+
onModeChange?.(value)
|
|
153
|
+
setModeMenuOpen(false)
|
|
154
|
+
},
|
|
155
|
+
[onModeChange]
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
// 选择模型
|
|
159
|
+
const selectModel = useCallback(
|
|
160
|
+
(m: ModelConfig) => {
|
|
161
|
+
onModelChange?.(m.model)
|
|
162
|
+
setModelMenuOpen(false)
|
|
163
|
+
},
|
|
164
|
+
[onModelChange]
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
// 图片 URL 处理
|
|
168
|
+
const getImageUrl = (path: string): string => {
|
|
169
|
+
if (
|
|
170
|
+
path.startsWith('app://') ||
|
|
171
|
+
path.startsWith('file://') ||
|
|
172
|
+
path.startsWith('data:') ||
|
|
173
|
+
path.startsWith('http')
|
|
174
|
+
) {
|
|
175
|
+
return path
|
|
176
|
+
}
|
|
177
|
+
if (path.match(/^[A-Z]:\\/i)) {
|
|
178
|
+
return `app://file${encodeURIComponent(path.replace(/\\/g, '/'))}`
|
|
179
|
+
}
|
|
180
|
+
return `app://file${encodeURIComponent(path)}`
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// 点击外部关闭菜单
|
|
184
|
+
useEffect(() => {
|
|
185
|
+
const handleClickOutside = (event: MouseEvent) => {
|
|
186
|
+
const target = event.target as HTMLElement
|
|
187
|
+
if (!target.closest('.selector')) {
|
|
188
|
+
setModeMenuOpen(false)
|
|
189
|
+
setModelMenuOpen(false)
|
|
190
|
+
}
|
|
191
|
+
if (
|
|
192
|
+
isMessageVariant &&
|
|
193
|
+
containerRef.current &&
|
|
194
|
+
!containerRef.current.contains(target)
|
|
195
|
+
) {
|
|
196
|
+
setIsFocused(false)
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
document.addEventListener('click', handleClickOutside)
|
|
201
|
+
return () => document.removeEventListener('click', handleClickOutside)
|
|
202
|
+
}, [isMessageVariant])
|
|
203
|
+
|
|
204
|
+
const CurrentModeIcon = currentMode.Icon
|
|
205
|
+
|
|
206
|
+
return (
|
|
207
|
+
<div className={`chat-input${isMessageVariant ? ' message-variant' : ''}`}>
|
|
208
|
+
<div
|
|
209
|
+
ref={containerRef}
|
|
210
|
+
className={`input-container${isFocused ? ' focused' : ''}`}
|
|
211
|
+
>
|
|
212
|
+
{/* 附件预览 */}
|
|
213
|
+
{selectedImages.length > 0 && (
|
|
214
|
+
<div className="attachment-preview">
|
|
215
|
+
<div className="preview-images">
|
|
216
|
+
{selectedPreview.map((img, index) => (
|
|
217
|
+
<div key={`${img}-${index}`} className="preview-item">
|
|
218
|
+
<img
|
|
219
|
+
src={getImageUrl(img)}
|
|
220
|
+
className="preview-thumb"
|
|
221
|
+
alt={`附件 ${index + 1}`}
|
|
222
|
+
onError={(e) => {
|
|
223
|
+
(e.target as HTMLImageElement).style.display = 'none'
|
|
224
|
+
}}
|
|
225
|
+
/>
|
|
226
|
+
{!isMessageVariant && (
|
|
227
|
+
<button
|
|
228
|
+
className="remove-btn"
|
|
229
|
+
title={`移除图片 ${index + 1}`}
|
|
230
|
+
onClick={() => onRemoveImage?.(index)}
|
|
231
|
+
>
|
|
232
|
+
<X size={10} />
|
|
233
|
+
</button>
|
|
234
|
+
)}
|
|
235
|
+
</div>
|
|
236
|
+
))}
|
|
237
|
+
{selectedImages.length > 3 && (
|
|
238
|
+
<div className="preview-more">+{selectedImages.length - 3}</div>
|
|
239
|
+
)}
|
|
240
|
+
</div>
|
|
241
|
+
</div>
|
|
242
|
+
)}
|
|
243
|
+
|
|
244
|
+
{/* 输入框 */}
|
|
245
|
+
<div className="input-field-wrapper">
|
|
246
|
+
<textarea
|
|
247
|
+
ref={inputRef}
|
|
248
|
+
value={inputText}
|
|
249
|
+
onChange={(e) => setInputText(e.target.value)}
|
|
250
|
+
onKeyDown={handleKeydown}
|
|
251
|
+
onInput={adjustTextareaHeight}
|
|
252
|
+
onFocus={() => setIsFocused(true)}
|
|
253
|
+
placeholder={placeholder}
|
|
254
|
+
rows={1}
|
|
255
|
+
className="input-field"
|
|
256
|
+
/>
|
|
257
|
+
</div>
|
|
258
|
+
|
|
259
|
+
{/* 底部控制栏 */}
|
|
260
|
+
{showToolbar && (
|
|
261
|
+
<div className="input-controls">
|
|
262
|
+
{/* 左侧:模式和模型选择 */}
|
|
263
|
+
<div className="input-left">
|
|
264
|
+
{/* 模式选择 */}
|
|
265
|
+
<div
|
|
266
|
+
className="selector mode-selector"
|
|
267
|
+
onClick={(e) => {
|
|
268
|
+
e.stopPropagation()
|
|
269
|
+
setModeMenuOpen(!modeMenuOpen)
|
|
270
|
+
setModelMenuOpen(false)
|
|
271
|
+
}}
|
|
272
|
+
>
|
|
273
|
+
<CurrentModeIcon size={12} />
|
|
274
|
+
<span>{currentMode.label}</span>
|
|
275
|
+
<ChevronDown size={10} className="chevron" />
|
|
276
|
+
|
|
277
|
+
{modeMenuOpen && (
|
|
278
|
+
<div className="dropdown-menu" onClick={(e) => e.stopPropagation()}>
|
|
279
|
+
{MODES.map((m) => (
|
|
280
|
+
<button
|
|
281
|
+
key={m.value}
|
|
282
|
+
className={`dropdown-item${mode === m.value ? ' active' : ''}`}
|
|
283
|
+
onClick={() => selectMode(m.value)}
|
|
284
|
+
>
|
|
285
|
+
<m.Icon size={14} />
|
|
286
|
+
<span>{m.label}</span>
|
|
287
|
+
{mode === m.value && <Check size={14} className="check-icon" />}
|
|
288
|
+
</button>
|
|
289
|
+
))}
|
|
290
|
+
</div>
|
|
291
|
+
)}
|
|
292
|
+
</div>
|
|
293
|
+
|
|
294
|
+
{/* 模型选择 */}
|
|
295
|
+
<div
|
|
296
|
+
className="selector model-selector"
|
|
297
|
+
onClick={(e) => {
|
|
298
|
+
e.stopPropagation()
|
|
299
|
+
setModelMenuOpen(!modelMenuOpen)
|
|
300
|
+
setModeMenuOpen(false)
|
|
301
|
+
}}
|
|
302
|
+
>
|
|
303
|
+
<span>{currentModel?.displayName || 'Auto'}</span>
|
|
304
|
+
<ChevronDown size={10} className="chevron" />
|
|
305
|
+
|
|
306
|
+
{modelMenuOpen && (
|
|
307
|
+
<div className="dropdown-menu" onClick={(e) => e.stopPropagation()}>
|
|
308
|
+
{models.map((m) => (
|
|
309
|
+
<button
|
|
310
|
+
key={m.model}
|
|
311
|
+
className={`dropdown-item${model === m.model ? ' active' : ''}`}
|
|
312
|
+
onClick={() => selectModel(m)}
|
|
313
|
+
>
|
|
314
|
+
<span>{m.displayName}</span>
|
|
315
|
+
{model === m.model && <Check size={14} className="check-icon" />}
|
|
316
|
+
</button>
|
|
317
|
+
))}
|
|
318
|
+
</div>
|
|
319
|
+
)}
|
|
320
|
+
</div>
|
|
321
|
+
</div>
|
|
322
|
+
|
|
323
|
+
{/* 右侧:功能按钮 */}
|
|
324
|
+
<div className="input-right">
|
|
325
|
+
<button className="icon-btn" title="提及上下文 (@)" onClick={onAtContext}>
|
|
326
|
+
<AtSign size={14} />
|
|
327
|
+
</button>
|
|
328
|
+
|
|
329
|
+
<button
|
|
330
|
+
className={`toggle-btn${thinkingEnabled ? ' active' : ''}`}
|
|
331
|
+
title="深度思考"
|
|
332
|
+
onClick={() => onThinkingChange?.(!thinkingEnabled)}
|
|
333
|
+
>
|
|
334
|
+
<Sparkles size={14} />
|
|
335
|
+
</button>
|
|
336
|
+
|
|
337
|
+
<button
|
|
338
|
+
className={`toggle-btn${webSearchEnabled ? ' active' : ''}`}
|
|
339
|
+
title="联网搜索"
|
|
340
|
+
onClick={() => onWebSearchChange?.(!webSearchEnabled)}
|
|
341
|
+
>
|
|
342
|
+
<Globe size={14} />
|
|
343
|
+
</button>
|
|
344
|
+
|
|
345
|
+
<button className="icon-btn" title="上传图片" onClick={onUploadImage}>
|
|
346
|
+
<ImageIcon size={14} />
|
|
347
|
+
</button>
|
|
348
|
+
|
|
349
|
+
{inputText.trim() || isLoading ? (
|
|
350
|
+
<button
|
|
351
|
+
className={`send-btn${isLoading ? ' loading' : ''}`}
|
|
352
|
+
title={isLoading ? '停止' : isMessageVariant ? '重新发送' : '发送'}
|
|
353
|
+
onClick={handleSendOrCancel}
|
|
354
|
+
>
|
|
355
|
+
{isLoading ? <Square size={14} /> : <ArrowUp size={14} />}
|
|
356
|
+
</button>
|
|
357
|
+
) : (
|
|
358
|
+
<button className="icon-btn" title="语音输入">
|
|
359
|
+
<Mic size={14} />
|
|
360
|
+
</button>
|
|
361
|
+
)}
|
|
362
|
+
</div>
|
|
363
|
+
</div>
|
|
364
|
+
)}
|
|
365
|
+
</div>
|
|
366
|
+
</div>
|
|
367
|
+
)
|
|
368
|
+
}
|