import type { ClipboardEvent, DragEvent, KeyboardEvent, ReactElement } from 'react' import type { Ask } from './qa' import type { PendingImage } from './qaImages' import type { RewriteRun } from './rewriteRun' import type { ProfileChoice, QaEntryView } from './types' import { ChevronDown, ChevronRight, CircleStop, LoaderCircle, SendHorizontal, Sparkles, Trash2, TriangleAlert, X } from 'lucide-react' import { useEffect, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { SelectMenu } from '@/components/ui/select-menu' import { renderMarkdown, renderMarkdownOnce } from './markdown' import { answerPreview, relativeTime } from './qa' import { MAX_IMAGES, pickImages, readImage, transferFiles } from './qaImages' /** 问题带的图片:一排小缩略图,点一张看大图;给了 onRemove 的(贴了还没发的)右上角有「去掉」。没有图片时什么都不画。 */ function Thumbs({ urls, pending, onOpen, onRemove }: { urls: string[], pending?: boolean, onOpen: (url: string) => void, onRemove?: (index: number) => void }): ReactElement | null { if (urls.length === 0) return null return (
{urls.map((url, index) => ( // 同一张图可能贴两次、地址一样;缩略图没有自己的状态,按位置认就行 {onRemove && ( )} ))}
) } /** 看大图:盖住整个预览,点哪儿或按 Esc 关上;打开时焦点在「关闭」上,关上后回到点开它的地方。 */ function ImagePreview({ url, onClose }: { url: string, onClose: () => void }): ReactElement { const close = useRef(null) useEffect(() => { const opener = document.activeElement close.current?.focus() return () => { if (opener instanceof HTMLElement) opener.focus() } }, []) const onKeyDown = (event: KeyboardEvent): void => { if (event.key === 'Escape') { // 只关大图:不收起问答区,也不清名字高亮(那边认 defaultPrevented) event.preventDefault() event.stopPropagation() onClose() } } return createPortal(
, document.body, ) } /** 问过的一次,灰字:问题、时间、「已理解,删除」,问题带的图片,下面回答的前两行;点问题或预览展开整段回答(照样灰字,按 Markdown 排) */ function HistoryItem({ entry, now, onDelete, onOpenImage }: { entry: QaEntryView, now: number, onDelete: () => void, onOpenImage: (url: string) => void }): ReactElement { const [expanded, setExpanded] = useState(false) const toggle = (): void => setExpanded(value => !value) return (
{entry.profile && {` · ${entry.profile}`}}
{expanded ?
: ( )}
) } /** 当前这一轮问的一次,照常颜色:问题,回答逐字长出来(按 Markdown 排),在做的事、出错、停止写在下面 */ function CurrentItem({ ask, onOpenImage, onStop }: { ask: Ask, onOpenImage: (url: string) => void, onStop?: () => void }): ReactElement { const running = ask.status === 'running' const label = ask.activity ?? (ask.text === '' ? '正在想…' : '正在回答…') return (
{ask.question}
{ask.text !== '' && (
)} {running && (
{onStop ? {label} : label} {onStop && ( )}
)} {ask.status === 'failed' && (
{ask.error}
)} {ask.status === 'stopped' &&
已停止,这次没存。
}
) } /** 底下的输入框:Enter 发送、Shift+Enter 换行、Esc 收起;正在答时发送换成停止。能贴图、拖图,贴的图在输入框上面一排,随下一个问题发 */ function AskInput({ running, first, profiles, onSelectProfile, onAsk, onStop, onClose, onOpenImage }: { running: Ask | undefined /** 这块还没问过:提示「问这块代码…」,问过了是「继续问…」 */ first: boolean profiles: ProfileChoice onSelectProfile: (id: string) => void onAsk: (question: string, images: PendingImage[]) => void onStop: (askId: string) => void onClose: () => void onOpenImage: (url: string) => void }): ReactElement { const [value, setValue] = useState('') const [images, setImages] = useState([]) /** 贴的、拖的有没加上的:给人看的原因,下次贴图或发送时清掉 */ const [notice, setNotice] = useState() const textarea = useRef(null) const attach = (files: File[]): void => { if (files.length === 0) return const picked = pickImages(files, images.length) setNotice(picked.notice) Promise.all(picked.accepted.map(readImage)).then( read => setImages(current => [...current, ...read].slice(0, MAX_IMAGES)), () => setNotice('有图片读不出来,没加上。'), ) } const send = (): void => { const question = value.trim() if (question === '' || running) return onAsk(question, images) setValue('') setImages([]) setNotice(undefined) } const onKeyDown = (event: KeyboardEvent): void => { // 输入法选字时的回车不算发送 if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) { event.preventDefault() send() } else if (event.key === 'Escape') { event.preventDefault() onClose() } } // 剪贴板里的图片加到上面的缩略图里;文字照常粘进输入框,不拦 const onPaste = (event: ClipboardEvent): void => attach(transferFiles(event.clipboardData.items)) const onDragOver = (event: DragEvent): void => { if (event.dataTransfer.types.includes('Files')) { event.preventDefault() event.dataTransfer.dropEffect = 'copy' } } const onDrop = (event: DragEvent): void => { const files = transferFiles(event.dataTransfer.items) if (files.length > 0) { event.preventDefault() attach(files) } } const remove = (index: number): void => { setImages(current => current.filter((_, i) => i !== index)) textarea.current?.focus() } return ( <> image.url)} pending onOpen={onOpenImage} onRemove={remove} /> {notice &&
{notice}
}