478 lines
21 KiB
TypeScript
478 lines
21 KiB
TypeScript
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 (
|
||
<div className="cr-qa-thumbs" data-pending={pending ? '' : undefined}>
|
||
{urls.map((url, index) => (
|
||
// 同一张图可能贴两次、地址一样;缩略图没有自己的状态,按位置认就行
|
||
<span key={index} className="cr-qa-thumb">
|
||
<button type="button" className="cr-qa-thumb-open" data-tooltip="看大图" aria-label={`看第 ${index + 1} 张图片的大图`} onClick={() => onOpen(url)}>
|
||
<img src={url} alt="" />
|
||
</button>
|
||
{onRemove && (
|
||
<button type="button" className="cr-qa-thumb-remove" data-tooltip="去掉这张图片" aria-label={`去掉第 ${index + 1} 张图片`} onClick={() => onRemove(index)}>
|
||
<X size={10} strokeWidth={2.4} aria-hidden />
|
||
</button>
|
||
)}
|
||
</span>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* 看大图:盖住整个预览,点哪儿或按 Esc 关上;打开时焦点在「关闭」上,关上后回到点开它的地方。
|
||
* 平时 portal 到 body;`inPlace` 时就画在原处(定义卡片浮窗里:指针在大图上也算在浮窗里,浮窗不收)。
|
||
*/
|
||
function ImagePreview({ url, inPlace = false, onClose }: { url: string, inPlace?: boolean, onClose: () => void }): ReactElement {
|
||
const close = useRef<HTMLButtonElement>(null)
|
||
useEffect(() => {
|
||
const opener = document.activeElement
|
||
close.current?.focus({ preventScroll: true })
|
||
return () => {
|
||
if (opener instanceof HTMLElement)
|
||
opener.focus({ preventScroll: true })
|
||
}
|
||
}, [])
|
||
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {
|
||
if (event.key === 'Escape') {
|
||
// 只关大图:不收起问答区,也不清名字高亮(那边认 defaultPrevented)
|
||
event.preventDefault()
|
||
event.stopPropagation()
|
||
onClose()
|
||
}
|
||
}
|
||
const lightbox = (
|
||
<div className="cr-qa-lightbox" role="dialog" aria-modal="true" aria-label="图片大图" onClick={onClose} onKeyDown={onKeyDown}>
|
||
<img src={url} alt="" />
|
||
<button ref={close} type="button" className="cr-qa-lightbox-close" data-tooltip="关闭(Esc)" aria-label="关闭大图">
|
||
<X size={16} strokeWidth={1.8} aria-hidden />
|
||
</button>
|
||
</div>
|
||
)
|
||
return inPlace ? lightbox : createPortal(lightbox, document.body)
|
||
}
|
||
|
||
/** 复制按钮在每种复制结果下的图标和名字。 */
|
||
const COPY_LOOKS: Record<CopyState, { icon: LucideIcon, label: string }> = {
|
||
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 (
|
||
<button type="button" className="cr-tool cr-qa-action" data-failed={state === 'failed' ? '' : undefined} data-tooltip={label} aria-label={label} onClick={() => void copy(answer)}>
|
||
<Icon size={13} strokeWidth={1.8} aria-hidden />
|
||
</button>
|
||
)
|
||
}
|
||
|
||
/** 说过的一次展开后的整段回答:按 Markdown 排,在历史里是灰字。 */
|
||
function HistoryAnswer({ answer }: { answer: string }): ReactElement {
|
||
return <MarkdownPreview html={renderMarkdown(answer, 'answer')} className="cr-qa-answer" />
|
||
}
|
||
|
||
/**
|
||
* 说过的一次,灰字:说的话、时间、复制回答、删除(问答叫「已理解,删除」),带的图片,下面回答的前两行;点问题或预览展开整段回答(照样灰字,按 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 (
|
||
<div className="cr-qa-entry" data-history="">
|
||
<div className="cr-qa-head">
|
||
<Icon size={12} strokeWidth={1.8} className="cr-qa-icon" aria-hidden />
|
||
<button type="button" className="cr-qa-question" aria-expanded={expanded} data-tooltip={expanded ? '收起回答' : '展开整段回答'} onClick={toggle}>
|
||
{entry.question}
|
||
</button>
|
||
<span className="cr-qa-when">
|
||
<time dateTime={entry.createdAt} data-tooltip={new Date(entry.createdAt).toLocaleString()}>
|
||
{relativeTime(entry.createdAt, now)}
|
||
</time>
|
||
{entry.profile && <span data-tooltip="用这个 Claude Code 配置(profile)答的">{` · ${entry.profile}`}</span>}
|
||
</span>
|
||
<CopyAnswerButton answer={entry.answer} />
|
||
<button type="button" className="cr-tool cr-qa-action" data-tooltip={wording.deleteLabel} aria-label={wording.deleteLabel} onClick={onDelete}>
|
||
<Trash2 size={13} strokeWidth={1.8} aria-hidden />
|
||
</button>
|
||
</div>
|
||
<Thumbs urls={entry.images ?? []} onOpen={onOpenImage} />
|
||
{expanded
|
||
? <HistoryAnswer answer={entry.answer} />
|
||
: (
|
||
<button type="button" className="cr-qa-preview" onClick={toggle}>
|
||
{answerPreview(entry.answer)}
|
||
</button>
|
||
)}
|
||
{entry.error && (
|
||
<div className="cr-qa-status" data-warning="">
|
||
<TriangleAlert size={12} strokeWidth={1.8} className="shrink-0" aria-hidden />
|
||
{`没写进文件:${entry.error}`}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/** 只读列表里问过的一次,灰字:只有问题一行,点了在下面展开带的图片和整段回答(同 [HistoryItem] 展开后的),再点收起。 */
|
||
function AskedItem({ entry, onOpenImage }: { entry: QaEntryView, onOpenImage: (url: string) => void }): ReactElement {
|
||
const [expanded, setExpanded] = useState(false)
|
||
return (
|
||
<div className="cr-qa-entry" data-history="">
|
||
<div className="cr-qa-head">
|
||
<Sparkles size={12} strokeWidth={1.8} className="cr-qa-icon" aria-hidden />
|
||
<button type="button" className="cr-qa-question" aria-expanded={expanded} data-tooltip={expanded ? '收起回答' : '展开回答'} onClick={() => setExpanded(value => !value)}>
|
||
{entry.question}
|
||
</button>
|
||
</div>
|
||
{expanded && (
|
||
<>
|
||
<Thumbs urls={entry.images ?? []} onOpen={onOpenImage} />
|
||
<HistoryAnswer answer={entry.answer} />
|
||
</>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/** 当前这一轮说的一次,照常颜色:说的话(答完了右边能复制回答),回答逐字长出来(按 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 (
|
||
<div className="cr-qa-entry" data-current="">
|
||
<div className="cr-qa-head">
|
||
<Icon size={12} strokeWidth={1.8} className="cr-qa-icon" aria-hidden />
|
||
<span className="cr-qa-question">{ask.question}</span>
|
||
{!unfinished(ask) && ask.text !== '' && <CopyAnswerButton answer={ask.text} />}
|
||
</div>
|
||
<Thumbs urls={ask.images ?? []} onOpen={onOpenImage} />
|
||
{ask.text !== '' && (
|
||
<MarkdownPreview
|
||
html={running ? renderMarkdownOnce(ask.text) : renderMarkdown(ask.text, 'answer')}
|
||
className="cr-qa-answer"
|
||
streaming={running}
|
||
/>
|
||
)}
|
||
{running && (
|
||
<div className="cr-qa-status" role="status">
|
||
<LoaderCircle size={12} strokeWidth={1.8} className="cr-qa-spin" aria-hidden />
|
||
{label}
|
||
</div>
|
||
)}
|
||
{ask.status === 'queued' && (
|
||
<div className="cr-qa-status" role="status">
|
||
<Hourglass size={12} strokeWidth={1.8} aria-hidden />
|
||
排队中,前面的写完就开始
|
||
</div>
|
||
)}
|
||
{ask.error !== undefined && !unfinished(ask) && (
|
||
<div className="cr-qa-error" role="alert">
|
||
<TriangleAlert size={13} strokeWidth={1.8} className="mt-[3px] shrink-0" aria-hidden />
|
||
<span>{ask.error}</span>
|
||
</div>
|
||
)}
|
||
{ask.status === 'stopped' && <div className="cr-qa-status">已停止,这次没存。</div>}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/** 底下的输入框: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<PendingImage[]>([])
|
||
/** 贴的、拖的有没加上的:给人看的原因,下次贴图或发送时清掉 */
|
||
const [notice, setNotice] = useState<string>()
|
||
const textarea = useRef<HTMLTextAreaElement>(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<HTMLTextAreaElement>): 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<HTMLTextAreaElement>): void => attach(transferFiles(event.clipboardData.items))
|
||
const onDragOver = (event: DragEvent<HTMLDivElement>): void => {
|
||
if (event.dataTransfer.types.includes('Files')) {
|
||
event.preventDefault()
|
||
event.dataTransfer.dropEffect = 'copy'
|
||
}
|
||
}
|
||
const onDrop = (event: DragEvent<HTMLDivElement>): 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 (
|
||
<>
|
||
<Thumbs urls={images.map(image => image.url)} pending onOpen={onOpenImage} onRemove={remove} />
|
||
{notice && <div className="cr-qa-notice" role="status">{notice}</div>}
|
||
<div className="cr-qa-input" onDragOver={onDragOver} onDrop={onDrop}>
|
||
<textarea
|
||
ref={textarea}
|
||
rows={1}
|
||
value={value}
|
||
placeholder={`${placeholder} Enter 发送${closable ? ',Esc 收起' : ''}`}
|
||
aria-label={label}
|
||
onChange={event => setValue(event.target.value)}
|
||
onKeyDown={onKeyDown}
|
||
onPaste={onPaste}
|
||
/>
|
||
<SelectMenu
|
||
variant="ghost"
|
||
value={profiles.selected}
|
||
options={profiles.options.map(option => ({ value: option.id, label: option.label }))}
|
||
onChange={onSelectProfile}
|
||
placeholder="默认"
|
||
tooltip="用哪个 Claude Code 配置(profile)回答,下一个问题起用它"
|
||
className="cr-qa-profile"
|
||
/>
|
||
{active
|
||
? (
|
||
<button type="button" className="cr-tool" data-tooltip={stopLabel} aria-label={stopLabel} onClick={() => onStop(active.askId)}>
|
||
<CircleStop size={14} strokeWidth={1.8} aria-hidden />
|
||
</button>
|
||
)
|
||
: (
|
||
<button type="button" className="cr-tool" data-tooltip="发送(Enter)" aria-label="发送" disabled={value.trim() === ''} onClick={send}>
|
||
<SendHorizontal size={14} strokeWidth={1.8} aria-hidden />
|
||
</button>
|
||
)}
|
||
</div>
|
||
</>
|
||
)
|
||
}
|
||
|
||
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<string>()
|
||
const lightbox = preview !== undefined && <ImagePreview url={preview} onClose={() => setPreview(undefined)} />
|
||
const Icon = wording.icon
|
||
const historyItems = history.map(entry => <HistoryItem key={entry.id} entry={entry} wording={wording} now={now} onDelete={() => onDelete(entry.id)} onOpenImage={setPreview} />)
|
||
if (!open) {
|
||
if (history.length === 0)
|
||
return null
|
||
const Chevron = showHistory ? ChevronDown : ChevronRight
|
||
return (
|
||
<div className="cr-qa" data-collapsed="">
|
||
<button type="button" className="cr-qa-summary" aria-expanded={showHistory} onClick={() => setShowHistory(value => !value)}>
|
||
<Icon size={12} strokeWidth={1.8} className="cr-qa-icon" aria-hidden />
|
||
{wording.summary(history.length)}
|
||
<Chevron size={12} strokeWidth={1.8} aria-hidden />
|
||
</button>
|
||
{showHistory && historyItems}
|
||
{lightbox}
|
||
</div>
|
||
)
|
||
}
|
||
return (
|
||
<section className="cr-qa" aria-label={wording.label}>
|
||
{historyItems}
|
||
{history.length > 0 && asks.length > 0 && <hr className="cr-qa-divider" />}
|
||
{asks.map(ask => <CurrentItem key={ask.askId} ask={ask} wording={wording} onOpenImage={setPreview} />)}
|
||
<AskInput
|
||
active={asks.find(unfinished)}
|
||
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}
|
||
</section>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* 只读的问答列表(定义卡片底部那一节):一行「问过 AI(N)」,下面按 `entries` 的先后只列问题,点一条展开 / 收起它的回答;不能删、不能接着问。
|
||
* 看大图画在列表自己里面(见 [ImagePreview] 的 `inPlace`),没问过时什么都不画。
|
||
*/
|
||
export function AskedList({ entries }: { entries: QaEntryView[] }): ReactElement | null {
|
||
/** 正在看大图的那张 */
|
||
const [preview, setPreview] = useState<string>()
|
||
if (entries.length === 0)
|
||
return null
|
||
return (
|
||
<section className="cr-qa" aria-label="问过 AI">
|
||
<div className="cr-qa-title">
|
||
<Sparkles size={12} strokeWidth={1.8} className="cr-qa-icon" aria-hidden />
|
||
{`问过 AI(${entries.length})`}
|
||
</div>
|
||
{entries.map(entry => <AskedItem key={entry.id} entry={entry} onOpenImage={setPreview} />)}
|
||
{preview !== undefined && <ImagePreview url={preview} inPlace onClose={() => setPreview(undefined)} />}
|
||
</section>
|
||
)
|
||
}
|