返回 oh-my-ppt
ThinkingChat.tsx
根目录 / src / renderer / src / components / thinking / ThinkingChat.tsx
1 import { useState, useRef, useEffect, useMemo, type KeyboardEvent, type ReactElement } from 'react'
2 import ReactMarkdown from 'react-markdown'
3 import { useT } from '@renderer/i18n'
4 import { useToastStore } from '@renderer/store'
5 import { ipc } from '@renderer/lib/ipc'
6 import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/Tooltip'
7 import {
8 Bot,
9 BookOpen,
10 Check,
11 ChevronDown,
12 ChevronRight,
13 FileSearch,
14 FileText,
15 FolderOpen,
16 Image as ImageIcon,
17 Loader2,
18 Paperclip,
19 Pencil,
20 Send,
21 User,
22 X
23 } from 'lucide-react'
24 import { ScrollArea } from '../ui/ScrollArea'
25 import type { ThinkingChatMessage, ThinkingSource } from '@shared/thinking'
26 import { ModelSelectButton } from '../model/ModelActionButton'
27 import { useModelAction } from '@renderer/hooks/useModelAction'
28
29 const MAX_DOCUMENT_SIZE_MB = 10
30 const MAX_DOCUMENT_SIZE_BYTES = MAX_DOCUMENT_SIZE_MB * 1024 * 1024
31 const MAX_IMAGE_SIZE_MB = 5
32 const MAX_IMAGE_SIZE_BYTES = MAX_IMAGE_SIZE_MB * 1024 * 1024
33 const SUPPORTED_DOCUMENT_EXTENSIONS = new Set(['.md', '.txt', '.text', '.csv', '.docx'])
34 const SUPPORTED_IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.webp'])
35
36 interface ThinkingStep {
37 type: 'tool_call' | 'tool_result'
38 toolName: string
39 summary: string
40 }
41
42 const getFileExtension = (name: string): string => {
43 const match = name
44 .trim()
45 .toLowerCase()
46 .match(/\.[^.]+$/)
47 return match?.[0] || ''
48 }
49
50 const isSupportedImageFile = (file: File): boolean => {
51 const ext = getFileExtension(file.name)
52 return SUPPORTED_IMAGE_EXTENSIONS.has(ext)
53 }
54
55 const isSupportedThinkingFile = (file: File): boolean => {
56 const ext = getFileExtension(file.name)
57 return SUPPORTED_DOCUMENT_EXTENSIONS.has(ext) || SUPPORTED_IMAGE_EXTENSIONS.has(ext)
58 }
59
60 function StepIcon({ step }: { step: ThinkingStep }): ReactElement {
61 const name = step.toolName
62 if (name === 'read_file') return <FolderOpen className="h-3 w-3 shrink-0 text-[#7a8fa6]" />
63 if (name === 'grep') return <FileSearch className="h-3 w-3 shrink-0 text-[#7a8fa6]" />
64 if (name === 'update_thinking_document')
65 return <Pencil className="h-3 w-3 shrink-0 text-[#8b7a5a]" />
66 if (name === 'update_context_document')
67 return <BookOpen className="h-3 w-3 shrink-0 text-[#6b8a6a]" />
68 return <Check className="h-3 w-3 shrink-0 text-[#8b967e]" />
69 }
70
71 interface ThinkingChatProps {
72 thinkingId: string
73 messages: ThinkingChatMessage[]
74 sources: ThinkingSource[]
75 pendingSources: ThinkingSource[]
76 loading: boolean
77 thinkingSteps: ThinkingStep[]
78 animatingText: string
79 onSend: (content: string, modelConfigId: string) => void
80 onSourcesUploaded: (sources: ThinkingSource[]) => void
81 onSourceRemoved: (sourceId: string) => void
82 }
83
84 function MessageMarkdown({
85 content,
86 role
87 }: {
88 content: string
89 role: ThinkingChatMessage['role']
90 }): ReactElement {
91 const isUser = role === 'user'
92 const mutedText = isUser ? 'text-white/85' : 'text-[#5f6658]'
93 const strongText = isUser ? 'text-white' : 'text-[#2f3329]'
94 const borderColor = isUser ? 'border-white/30' : 'border-[#d7ddcf]'
95 const listClass = isUser
96 ? 'mb-2 list-disc space-y-1 pl-5 text-[13px] leading-relaxed marker:text-white/70'
97 : 'mb-2 list-disc space-y-1 pl-5 text-[13px] leading-relaxed marker:text-[#8b967e]'
98 const orderedListClass = isUser
99 ? 'mb-2 list-decimal space-y-1 pl-5 text-[13px] leading-relaxed marker:text-white/70'
100 : 'mb-2 list-decimal space-y-1 pl-5 text-[13px] leading-relaxed marker:text-[#8b967e]'
101 const codeClass = isUser
102 ? 'rounded bg-white/15 px-1 py-0.5 font-mono text-[12px] text-white'
103 : 'rounded bg-[#edf0e7] px-1 py-0.5 font-mono text-[12px] text-[#2f3329]'
104
105 return (
106 <div className="markdown-message [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
107 <ReactMarkdown
108 components={{
109 p: ({ children }) => (
110 <p
111 className={`mb-2 whitespace-pre-wrap text-[13px] leading-relaxed ${isUser ? 'text-white' : 'text-[#2f3329]'}`}
112 >
113 {children}
114 </p>
115 ),
116 strong: ({ children }) => (
117 <strong className={`font-semibold ${strongText}`}>{children}</strong>
118 ),
119 em: ({ children }) => <em className={mutedText}>{children}</em>,
120 ul: ({ children }) => <ul className={listClass}>{children}</ul>,
121 ol: ({ children }) => <ol className={orderedListClass}>{children}</ol>,
122 li: ({ children }) => (
123 <li className={isUser ? 'text-white' : 'text-[#2f3329]'}>{children}</li>
124 ),
125 code: ({ children }) => <code className={codeClass}>{children}</code>,
126 pre: ({ children }) => (
127 <pre
128 className={`mb-2 overflow-x-auto rounded-md p-3 text-[12px] leading-relaxed ${isUser ? 'bg-black/15 text-white' : 'bg-[#edf0e7] text-[#2f3329]'}`}
129 >
130 {children}
131 </pre>
132 ),
133 blockquote: ({ children }) => (
134 <blockquote
135 className={`mb-2 border-l-2 pl-3 text-[13px] leading-relaxed ${borderColor} ${mutedText}`}
136 >
137 {children}
138 </blockquote>
139 ),
140 a: ({ children, href }) => (
141 <a
142 href={href}
143 target="_blank"
144 rel="noreferrer"
145 className={
146 isUser
147 ? 'underline decoration-white/50 underline-offset-2'
148 : 'text-[#466938] underline underline-offset-2'
149 }
150 >
151 {children}
152 </a>
153 )
154 }}
155 >
156 {content}
157 </ReactMarkdown>
158 </div>
159 )
160 }
161
162 export function ThinkingChat({
163 thinkingId,
164 messages,
165 sources,
166 pendingSources,
167 loading,
168 thinkingSteps,
169 animatingText,
170 onSend,
171 onSourcesUploaded,
172 onSourceRemoved
173 }: ThinkingChatProps): ReactElement {
174 const t = useT()
175 const { success, error: toastError } = useToastStore()
176 const modelAction = useModelAction()
177 const [input, setInput] = useState('')
178 const [uploading, setUploading] = useState(false)
179 const [removingSourceId, setRemovingSourceId] = useState<string | null>(null)
180 const [thinkingExpanded, setThinkingExpanded] = useState(true)
181 const scrollRef = useRef<HTMLDivElement>(null)
182 const fileInputRef = useRef<HTMLInputElement>(null)
183 const composingRef = useRef(false)
184
185 const visibleThinkingSteps = useMemo(
186 () => thinkingSteps.filter((step) => step.type === 'tool_call' && step.summary.trim()),
187 [thinkingSteps]
188 )
189
190 useEffect(() => {
191 const el = scrollRef.current
192 if (!el) return
193
194 requestAnimationFrame(() => {
195 el.scrollTo({
196 top: el.scrollHeight,
197 behavior: 'smooth'
198 })
199 })
200 }, [messages, loading, visibleThinkingSteps, animatingText])
201
202 const handleSend = async (): Promise<void> => {
203 const text = input.trim()
204 if (!text || loading || modelAction.activatingModelConfigId) return
205 const modelConfigId = await modelAction.ensureModelActive()
206 if (!modelConfigId) return
207 onSend(text, modelConfigId)
208 setInput('')
209 }
210
211 const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>): void => {
212 if (e.key === 'Enter' && !e.shiftKey) {
213 if (composingRef.current || e.nativeEvent.isComposing) return
214 e.preventDefault()
215 void handleSend()
216 }
217 }
218
219 const handleAttachClick = (): void => {
220 fileInputRef.current?.click()
221 }
222
223 const handleFilesSelected = async (files: FileList | null): Promise<void> => {
224 const selectedFiles = Array.from(files || [])
225 if (fileInputRef.current) {
226 fileInputRef.current.value = ''
227 }
228 if (selectedFiles.length === 0) return
229
230 const unsupportedFile = selectedFiles.find((file) => !isSupportedThinkingFile(file))
231 if (unsupportedFile) {
232 toastError(t('home.unsupportedFileTitle'), {
233 description: t('thinking.uploadTooltip', {
234 documentMaxSize: MAX_DOCUMENT_SIZE_MB,
235 imageMaxSize: MAX_IMAGE_SIZE_MB
236 })
237 })
238 return
239 }
240
241 const oversizedFile = selectedFiles.find((file) => {
242 const maxSizeBytes = isSupportedImageFile(file)
243 ? MAX_IMAGE_SIZE_BYTES
244 : MAX_DOCUMENT_SIZE_BYTES
245 return file.size > maxSizeBytes
246 })
247 if (oversizedFile) {
248 const isImage = isSupportedImageFile(oversizedFile)
249 toastError(t('home.documentTooLargeTitle'), {
250 description: isImage
251 ? t('home.imageTooLarge', { maxSize: MAX_IMAGE_SIZE_MB })
252 : t('home.documentTooLarge', { maxSize: MAX_DOCUMENT_SIZE_MB })
253 })
254 return
255 }
256
257 const payloadFiles = selectedFiles
258 .map((file) => ({
259 path: window.electron?.getPathForFile?.(file) || '',
260 name: file.name
261 }))
262 .filter((file) => file.path)
263
264 if (payloadFiles.length === 0) return
265
266 setUploading(true)
267 try {
268 const result = await ipc.thinkingUploadSources({
269 thinkingId,
270 files: payloadFiles
271 })
272 onSourcesUploaded(
273 result.sources.map((s) => ({
274 id: s.id,
275 name: s.name,
276 kind: s.kind as ThinkingSource['kind']
277 }))
278 )
279 const hasDocumentFile = payloadFiles.some((file) =>
280 SUPPORTED_DOCUMENT_EXTENSIONS.has(getFileExtension(file.name))
281 )
282 success(t('thinking.sourceUploaded'), {
283 description: hasDocumentFile ? t('thinking.sourcePreprocessHint') : undefined
284 })
285 } catch (err) {
286 toastError(t('thinking.uploadFailed'), {
287 description: err instanceof Error ? err.message : t('common.retryLater')
288 })
289 } finally {
290 setUploading(false)
291 }
292 }
293
294 const handleRemoveSource = async (sourceId: string): Promise<void> => {
295 if (loading || removingSourceId) return
296 setRemovingSourceId(sourceId)
297 try {
298 await ipc.thinkingRemoveSource({ thinkingId, sourceId })
299 onSourceRemoved(sourceId)
300 } catch (err) {
301 toastError(t('thinking.removeSourceFailed'), {
302 description: err instanceof Error ? err.message : t('common.retryLater')
303 })
304 } finally {
305 setRemovingSourceId(null)
306 }
307 }
308
309 const sourceIcon = (kind: ThinkingSource['kind']): ReactElement =>
310 kind === 'image' ? <ImageIcon className="h-3 w-3" /> : <FileText className="h-3 w-3" />
311
312 return (
313 <div className="flex h-full min-h-0 flex-col">
314 <ScrollArea className="flex-1 px-5 py-5" viewportRef={scrollRef}>
315 {sources.length > 0 && (
316 <div className="mb-4 flex justify-end">
317 <div className="rounded-full bg-[#d4e4c1] px-3 py-1 text-[11px] font-semibold text-[#5d6b4d]">
318 {t('thinking.sourceCount', { count: sources.length })}
319 </div>
320 </div>
321 )}
322 <div className="space-y-4">
323 {messages.map((msg, idx) => (
324 <div
325 key={idx}
326 className={`flex gap-3 ${msg.role === 'user' ? 'flex-row-reverse' : ''}`}
327 >
328 <div
329 className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-[5%_95%_10%_90%/85%_15%_85%_15%] ${
330 msg.role === 'user' ? 'bg-[#5d6b4d] text-white' : 'bg-[#8fbc8f] text-white'
331 }`}
332 >
333 {msg.role === 'user' ? <User className="h-4 w-4" /> : <Bot className="h-4 w-4" />}
334 </div>
335 <div
336 className={`max-w-[78%] rounded-[1.5rem] px-4 py-3 text-[13px] leading-relaxed shadow-sm ${
337 msg.role === 'user'
338 ? 'bg-[#5d6b4d] text-white'
339 : 'border border-[#e0d8c8] bg-[#f5f1e8] text-[#2f3329]'
340 }`}
341 >
342 <MessageMarkdown content={msg.content} role={msg.role} />
343 {msg.attachments && msg.attachments.length > 0 && (
344 <div className="mt-2 flex flex-wrap gap-1.5">
345 {msg.attachments.map((att) => (
346 <span
347 key={att.id}
348 className={`inline-flex max-w-[200px] items-center gap-1.5 rounded-full px-2.5 py-1 text-[10px] font-medium ${
349 msg.role === 'user'
350 ? 'border border-white/20 bg-white/15 text-white/90'
351 : 'border border-[#c8d6ba] bg-[#d4e4c1] text-[#4f6340]'
352 }`}
353 >
354 {sourceIcon(att.kind)}
355 <span className="truncate">{att.name}</span>
356 </span>
357 ))}
358 </div>
359 )}
360 </div>
361 </div>
362 ))}
363 {loading && (
364 <div className="flex gap-3">
365 <div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-[5%_95%_10%_90%/85%_15%_85%_15%] bg-[#8fbc8f] text-white">
366 <Bot className="h-4 w-4" />
367 </div>
368 <div className="max-w-[78%] space-y-2">
369 {/* Thinking process - collapsible */}
370 {visibleThinkingSteps.length > 0 && (
371 <button
372 type="button"
373 onClick={() => setThinkingExpanded(!thinkingExpanded)}
374 className="flex w-[180px] items-center gap-1.5 rounded-full border border-[#e0d8c8] bg-[#e8e0d0] px-3 py-2 text-left text-[11px] text-[#5d6b4d] transition-colors hover:bg-[#d4e4c1]"
375 >
376 {thinkingExpanded ? (
377 <ChevronDown className="h-3 w-3 shrink-0" />
378 ) : (
379 <ChevronRight className="h-3 w-3 shrink-0" />
380 )}
381 <span className="font-medium">{t('thinking.thinking')}</span>
382 <Loader2 className="ml-1 h-3 w-3 animate-spin" />
383 </button>
384 )}
385 {thinkingExpanded && visibleThinkingSteps.length > 0 && (
386 <div className="w-[180px] rounded-[1.25rem] border border-[#e0d8c8] bg-[#f5f1e8]">
387 <div className="space-y-1.5 px-3 py-2">
388 {visibleThinkingSteps.map((step, idx) => (
389 <div
390 key={`${step.toolName}-${step.summary}-${idx}`}
391 className="flex items-start gap-1.5 text-[11px] leading-relaxed text-[#7a7060]"
392 >
393 <StepIcon step={step} />
394 <span className="min-w-0 break-words">{step.summary}</span>
395 </div>
396 ))}
397 </div>
398 </div>
399 )}
400 {/* Animated response text */}
401 {animatingText ? (
402 <div className="rounded-[1.5rem] border border-[#e0d8c8] bg-[#f5f1e8] px-4 py-3 text-[13px] leading-relaxed shadow-sm">
403 <MessageMarkdown content={animatingText} role="assistant" />
404 </div>
405 ) : visibleThinkingSteps.length === 0 ? (
406 <div className="w-[180px] rounded-[1.5rem] border border-[#e0d8c8] bg-[#f5f1e8] px-4 py-3 text-[13px] text-[#5d6b4d] shadow-sm">
407 <Loader2 className="mr-1.5 inline h-3.5 w-3.5 animate-spin align-[-2px]" />
408 {t('thinking.thinking')}
409 </div>
410 ) : null}
411 </div>
412 </div>
413 )}
414 </div>
415 </ScrollArea>
416
417 <div className="border-t border-[#e0d8c8] bg-[#fffdf8] px-4 py-3">
418 <div className="rounded-xl border border-[#e0d8c8] bg-[#f5f1e8] px-2 py-2 shadow-sm focus-within:border-[#8fbc8f] focus-within:ring-2 focus-within:ring-[#d4e4c1]">
419 {pendingSources.length > 0 && (
420 <div className="flex max-h-16 flex-wrap gap-1.5 overflow-y-auto px-2 pb-1.5">
421 {pendingSources.map((source) => (
422 <span
423 key={source.id}
424 className="inline-flex max-w-[240px] items-center gap-1.5 rounded-full border border-[#b8cca5] bg-[#d4e4c1] px-2.5 py-1 text-[10px] font-medium text-[#4f6340]"
425 >
426 {sourceIcon(source.kind)}
427 <span className="truncate">{source.name}</span>
428 <button
429 type="button"
430 className="ml-0.5 inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full transition-colors hover:bg-[#b9cfa6] disabled:opacity-40"
431 onClick={() => void handleRemoveSource(source.id)}
432 disabled={loading || removingSourceId === source.id}
433 title={t('thinking.removeSource')}
434 >
435 {removingSourceId === source.id ? (
436 <Loader2 className="h-3 w-3 animate-spin" />
437 ) : (
438 <X className="h-3 w-3" />
439 )}
440 </button>
441 </span>
442 ))}
443 </div>
444 )}
445 <div className="flex items-end gap-2">
446 <TooltipProvider delayDuration={300}>
447 <Tooltip>
448 <TooltipTrigger asChild>
449 <button
450 type="button"
451 onClick={handleAttachClick}
452 disabled={loading || uploading}
453 className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-[#5d6b4d] transition-colors hover:bg-[#d4e4c1] hover:text-[#3e4a32] disabled:opacity-40"
454 >
455 {uploading ? (
456 <Loader2 className="h-4 w-4 animate-spin" />
457 ) : (
458 <Paperclip className="h-4 w-4" />
459 )}
460 </button>
461 </TooltipTrigger>
462 <TooltipContent side="top" className="max-w-[240px] text-[12px]">
463 {t('thinking.uploadTooltip', {
464 documentMaxSize: MAX_DOCUMENT_SIZE_MB,
465 imageMaxSize: MAX_IMAGE_SIZE_MB
466 })}
467 </TooltipContent>
468 </Tooltip>
469 </TooltipProvider>
470 <textarea
471 className="max-h-36 min-h-[44px] flex-1 resize-none border-0 bg-transparent px-2 py-2.5 text-[13px] leading-relaxed text-[#2f3329] placeholder:text-[#9a9b8c] focus:outline-none"
472 placeholder={t('thinking.inputPlaceholder')}
473 rows={2}
474 value={input}
475 onChange={(e) => setInput(e.target.value)}
476 onCompositionStart={() => {
477 composingRef.current = true
478 }}
479 onCompositionEnd={() => {
480 composingRef.current = false
481 }}
482 onKeyDown={handleKeyDown}
483 disabled={loading}
484 />
485 <div className="flex shrink-0 items-center gap-1">
486 <ModelSelectButton modelAction={modelAction} disabled={loading} />
487 <button
488 type="button"
489 onClick={() => void handleSend()}
490 disabled={loading || Boolean(modelAction.activatingModelConfigId) || !input.trim()}
491 className="flex h-8 w-8 items-center justify-center rounded-full bg-[#3e4a32] text-white transition-colors hover:bg-[#5d6b4d] disabled:opacity-40 disabled:hover:bg-[#3e4a32]"
492 >
493 <Send className="h-4 w-4" />
494 </button>
495 </div>
496 </div>
497 </div>
498 <input
499 ref={fileInputRef}
500 type="file"
501 accept=".md,.txt,.text,.csv,.docx,.png,.jpg,.jpeg,.webp"
502 multiple
503 className="hidden"
504 onChange={(event) => void handleFilesSelected(event.target.files)}
505 />
506 </div>
507 </div>
508 )
509 }
510
510 lines Plain Text