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 { ChevronDown, ChevronRight, CircleStop, LoaderCircle, MessageSquareText, 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'
/** 一种对话在界面上怎么称呼:区域的名字、图标、「问过 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: '正在回答…',
}
/** 重写注释:第一轮由块右上角的按钮发技能命令,输入框里说的是对这段注释的修改意见 */
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 关上;打开时焦点在「关闭」上,关上后回到点开它的地方。 */
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, 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}`}
)}
)
}
/** 当前这一轮说的一次,照常颜色:说的话,回答逐字长出来(按 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}
{ask.text !== '' && (
)}
{running && (
{label}
)}
{ask.error !== undefined && ask.status !== 'running' && (
{ask.error}
)}
{ask.status === 'stopped' &&
已停止,这次没存。
}
)
}
/** 底下的输入框:Enter 发送、Shift+Enter 换行、Esc 收起;正在答时发送换成停止。能贴图、拖图,贴的图在输入框上面一排,随下一个问题发 */
function AskInput({ running, placeholder, label, profiles, onSelectProfile, onAsk, onStop, onClose, closable, onOpenImage, autoFocus, focusToken }: {
running: 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 [images, setImages] = useState([])
/** 贴的、拖的有没加上的:给人看的原因,下次贴图或发送时清掉 */
const [notice, setNotice] = useState()
const textarea = useRef(null)
useEffect(() => {
if (autoFocus)
textarea.current?.focus()
}, [autoFocus])
useEffect(() => {
if (focusToken > 0)
textarea.current?.focus()
}, [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 === '' || 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 (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))
textarea.current?.focus()
}
return (
<>
image.url)} pending onOpen={onOpenImage} onRemove={remove} />
{notice && {notice}
}
>
)
}
export interface AskThreadProps {
history: QaEntryView[]
asks: Ask[]
/** 点了「问 AI」:展开全部历史、当前这一轮和输入框 */
open: boolean
/** 输入框旁边的 profile 下拉 */
profiles: ProfileChoice
/** 「问」后面的对象,默认「这块代码」 */
hint?: string
/** 哪种对话,默认问 AI */
wording?: ThreadWording
/** 展开时聚焦输入框,默认聚焦。一直摊开的顶部聊天不聚焦 */
autoFocus?: boolean
/** 变大时把光标放进输入框 */
focusToken?: number
/** Esc 能不能收起,默认能。顶部聊天不收 */
closable?: boolean
now?: number
onSelectProfile: (id: string) => void
/** images 是贴进输入框的图片,随这句话一起发 */
onAsk: (question: string, images: PendingImage[]) => void
onStop: (askId: string) => void
onDelete: (entryId: string) => void
onClose: () => void
}
/**
* 块下面的对话区(问 AI、重写注释,左边一条竖线):展开时是灰字的历史、当前这一轮、输入框;
* 没展开但说过的块是一行「问过 N 次」(重写注释是「重写过 N 次」)、下面默认摊开灰字历史,点这一行收起 / 再摊开,读代码时一眼看到这里说过什么。
*/
export function AskThread({ history, asks, open, profiles, hint = '这块代码', wording = ASK_THREAD, autoFocus = true, focusToken = 0, closable = true, now = Date.now(), onSelectProfile, onAsk, onStop, onDelete, onClose }: AskThreadProps): ReactElement | null {
const [showHistory, setShowHistory] = useState(true)
/** 正在看大图的那张 */
const [preview, setPreview] = useState()
const lightbox = preview !== undefined && setPreview(undefined)} />
const Icon = wording.icon
const historyItems = history.map(entry => onDelete(entry.id)} onOpenImage={setPreview} />)
if (!open) {
if (history.length === 0)
return null
const Chevron = showHistory ? ChevronDown : ChevronRight
return (
{showHistory && historyItems}
{lightbox}
)
}
return (
{historyItems}
{history.length > 0 && asks.length > 0 &&
}
{asks.map(ask => )}
ask.status === 'running')}
placeholder={wording.placeholder(history.length === 0 && asks.length === 0, hint)}
label={wording.inputLabel}
profiles={profiles}
onSelectProfile={onSelectProfile}
onAsk={onAsk}
onStop={onStop}
onClose={onClose}
closable={closable}
onOpenImage={setPreview}
autoFocus={autoFocus}
focusToken={focusToken}
/>
{lightbox}
)
}