@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/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
+ }