Files
superwork/vscode/webview-ui/src/codeReader/AskThread.tsx
T

478 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}