import type { LucideIcon } from 'lucide-react' import type { ClipboardEvent, DragEvent, KeyboardEvent, ReactElement } from 'react' import type { Ask } from './qa' import type { PendingImage } from './qaImages' import type { ProfileChoice, QaEntryView } from './types' import type { CopyState } from '@/hooks/useCopyText' import { Check, ChevronDown, ChevronRight, CircleStop, Copy, Hourglass, LoaderCircle, MessageSquareText, Search, 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 { useCopyText } from '@/hooks/useCopyText' import { renderMarkdown, renderMarkdownOnce } from './markdown' import { answerPreview, relativeTime, unfinished } from './qa' import { MAX_IMAGES, pickImages, readImage, transferFiles } from './qaImages' import { MarkdownPreview } from './TextPreview' import { showInViewport } from './useReveal' /** 一种对话在界面上怎么称呼:区域的名字、图标、「问过 N 次」、删一条、输入框的提示和名字、回答长出来时的状态 */ export interface ThreadWording { label: string icon: LucideIcon summary: (count: number) => string deleteLabel: string /** first:还没说过;hint:「问」后面的对象 */ placeholder: (first: boolean, hint: string) => string inputLabel: string answering: string } export const ASK_THREAD: ThreadWording = { label: '问 AI', icon: Sparkles, summary: count => `问过 ${count} 次`, deleteLabel: '已理解,删除', placeholder: (first, hint) => first ? `问${hint}…` : '继续问…', inputLabel: '问 AI 的问题', answering: '正在回答…', } /** 「了解一块代码」的对话页:第一轮是侧栏里问的那句话,输入框里接着说哪里漏了、哪些不相关,AI 改完整栏换成新结果 */ export const FEATURE_THREAD: ThreadWording = { label: '了解一块代码', icon: Search, summary: count => `说过 ${count} 次`, deleteLabel: '删除这一轮', placeholder: () => '接着说:还漏了什么、哪些不相关…', inputLabel: '接着说的话', answering: '正在找…', } /** 重写注释:第一轮由块右上角的按钮发技能命令,输入框里说的是对这段注释的修改意见 */ export const REWRITE_THREAD: ThreadWording = { label: '按注释规范重写', icon: MessageSquareText, summary: count => `重写过 ${count} 次`, deleteLabel: '删除这条记录', placeholder: () => '说说这段注释要怎么改…', inputLabel: '对这段注释的修改意见', answering: '正在写…', } /** 问题带的图片:一排小缩略图,点一张看大图;给了 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 关上;打开时焦点在「关闭」上,关上后回到点开它的地方。 * 平时 portal 到 body;`inPlace` 时就画在原处(定义卡片浮窗里:指针在大图上也算在浮窗里,浮窗不收)。 */ function ImagePreview({ url, inPlace = false, onClose }: { url: string, inPlace?: boolean, onClose: () => void }): ReactElement { const close = useRef(null) useEffect(() => { const opener = document.activeElement close.current?.focus({ preventScroll: true }) return () => { if (opener instanceof HTMLElement) opener.focus({ preventScroll: true }) } }, []) const onKeyDown = (event: KeyboardEvent): void => { if (event.key === 'Escape') { // 只关大图:不收起问答区,也不清名字高亮(那边认 defaultPrevented) event.preventDefault() event.stopPropagation() onClose() } } const lightbox = (
) return inPlace ? lightbox : createPortal(lightbox, document.body) } /** 复制按钮在每种复制结果下的图标和名字。 */ const COPY_LOOKS: Record = { idle: { icon: Copy, label: '复制回答' }, copied: { icon: Check, label: '已复制' }, failed: { icon: TriangleAlert, label: '复制失败' }, } /** 头部右边的「复制回答」:把 `answer` 原样的 Markdown 连同里面的仓库链接放进剪贴板,好拿到别的会话里接着用;点完图标和名字换成复制的结果,两秒后复原。 */ function CopyAnswerButton({ answer }: { answer: string }): ReactElement { const { state, copy } = useCopyText() const { icon: Icon, label } = COPY_LOOKS[state] return ( ) } /** 说过的一次展开后的整段回答:按 Markdown 排,在历史里是灰字。 */ function HistoryAnswer({ answer }: { answer: string }): ReactElement { return } /** * 说过的一次,灰字:说的话、时间、复制回答、删除(问答叫「已理解,删除」),带的图片,下面回答的前两行;点问题或预览展开整段回答(照样灰字,按 Markdown 排)。 * 重写记录没写进文件时,最下面写着原因 */ function HistoryItem({ entry, wording, now, onDelete, onOpenImage }: { entry: QaEntryView, wording: ThreadWording, now: number, onDelete: () => void, onOpenImage: (url: string) => void }): ReactElement { const [expanded, setExpanded] = useState(false) const toggle = (): void => setExpanded(value => !value) const Icon = wording.icon return (
{entry.profile && {` · ${entry.profile}`}}
{expanded ? : ( )} {entry.error && (
{`没写进文件:${entry.error}`}
)}
) } /** 只读列表里问过的一次,灰字:只有问题一行,点了在下面展开带的图片和整段回答(同 [HistoryItem] 展开后的),再点收起。 */ function AskedItem({ entry, onOpenImage }: { entry: QaEntryView, onOpenImage: (url: string) => void }): ReactElement { const [expanded, setExpanded] = useState(false) return (
{expanded && ( <> )}
) } /** 当前这一轮说的一次,照常颜色:说的话(答完了右边能复制回答),回答逐字长出来(按 Markdown 排),排着队、在做的事、出错(重写注释没写进文件也算)、停止写在下面 */ function CurrentItem({ ask, wording, onOpenImage }: { ask: Ask, wording: ThreadWording, onOpenImage: (url: string) => void }): ReactElement { const running = ask.status === 'running' const label = ask.activity ?? (ask.text === '' ? '正在想…' : wording.answering) const Icon = wording.icon return (
{ask.question} {!unfinished(ask) && ask.text !== '' && }
{ask.text !== '' && ( )} {running && (
{label}
)} {ask.status === 'queued' && (
排队中,前面的写完就开始
)} {ask.error !== undefined && !unfinished(ask) && (
{ask.error}
)} {ask.status === 'stopped' &&
已停止,这次没存。
}
) } /** 底下的输入框:Enter 发送、Shift+Enter 换行、Esc 收起;正在答时发送换成停止,排着队时换成取消排队。能贴图、拖图,贴的图在输入框上面一排,随下一个问题发 */ /** 把光标放进输入框;它不在视口里时交给[滚动所有者](vscode/webview-ui/src/codeReader/scrollOwner.ts#ScrollOwner)滚过去(见 [showInViewport]),不让浏览器自己滚。 */ function focusField(field: HTMLTextAreaElement | null): void { if (!field) return field.focus({ preventScroll: true }) showInViewport(() => field.isConnected ? field : null) } function AskInput({ active, placeholder, label, profiles, onSelectProfile, onAsk, onStop, onClose, closable, onOpenImage, autoFocus, focusToken }: { /** 正在答或排着队的那一轮 */ active: Ask | undefined /** 输入框里的提示(后面接着怎么发送、收起) */ placeholder: string /** 输入框读屏用的名字 */ label: string profiles: ProfileChoice onSelectProfile: (id: string) => void onAsk: (question: string, images: PendingImage[]) => void onStop: (askId: string) => void onClose: () => void /** Esc 收起。顶部那条一直摊开,不收 */ closable: boolean onOpenImage: (url: string) => void /** 展开时把光标放进输入框。顶部那条一直摊开,不要抢阅读时光标 */ autoFocus: boolean /** 大于 0 时再放一次光标;侧栏「问 AI」打开已经在的预览时用 */ focusToken: number }): ReactElement { const [value, setValue] = useState('') const stopLabel = active?.status === 'queued' ? '取消排队' : '停止' const [images, setImages] = useState([]) /** 贴的、拖的有没加上的:给人看的原因,下次贴图或发送时清掉 */ const [notice, setNotice] = useState() const textarea = useRef(null) useEffect(() => { if (autoFocus) focusField(textarea.current) }, [autoFocus]) useEffect(() => { if (focusToken > 0) focusField(textarea.current) }, [focusToken]) 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 === '' || active) 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 (closable && 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)) focusField(textarea.current) } return ( <> image.url)} pending onOpen={onOpenImage} onRemove={remove} /> {notice &&
{notice}
}